# MIT License # # Copyright (c) 2026 # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # Third-party exception: bitmap glyph data derived from Noto Sans CJK is licensed # under the SIL Open Font License 1.1. See assets/NOTO-LICENSE.txt. """081 交代灯台守: 教材段階 1。""" STAGE = 1 from dataclasses import dataclass, replace, asdict DUE = 3 SHIP_LANE = 'M' LANES = ('L', 'M', 'R') @dataclass(frozen=True) class Lab: t: int = 0 light: str = 'L' battery: int = 1 result: str = '' def initial(): return Lab() def legal(s): return () if s.result else ('L','M','R','wait') def advance(s, action): if action not in legal(s): return s light = action if action in LANES else s.light t = s.t + 1 battery = s.battery result = '' if t == DUE: arrived = light == SHIP_LANE and battery >= 1 if arrived: battery -= 1 result = 'success' if arrived else 'missed' return Lab(t, light, battery, result) # Teaching interface: ASCII rendering and direct stairs, separate from original Controller. class App: def __init__(self): self.level_index = 0 self.compare = False self.gated = False self.reset() @property def board(self): return BOARDS[self.level_index] if STAGE >= 3 else None @property def state(self): return self.timeline.state if STAGE == 5 else self.s def reset(self): if STAGE == 5: self.timeline = Timeline(self.board) else: self.s = new(self.board) if STAGE >= 3 else initial() self.selection = 0 self.message = 'READY / SELECTION DOES NOT ADVANCE TIME' def commit(self, action): before = self.state if STAGE == 5: self.timeline.act(action) else: self.s = step(self.board, self.s, action) if STAGE >= 3 else advance(self.s, action) self.selection = 0 self.message = ('ACCEPTED: ' if before != self.state else 'UNCHANGED: ') + action.upper() def press(self, key): if key == 'R': self.reset() return if STAGE >= 3 and key in ('1','2','3'): self.level_index = int(key) - 1 self.reset() return if STAGE >= 4 and key == 'B': self.compare = not self.compare self.message = 'DISPLAY COMPARISON ONLY / NO TICK OR RESET' return if STAGE == 5 and key == 'X': changed = self.timeline.undo() self.selection = 0 self.message = 'UNDO ONE TICK' if changed else 'NOTHING TO UNDO' return choices = legal(self.state) if not choices: return if key in ('LEFT','RIGHT'): self.selection = (self.selection + (1 if key == 'RIGHT' else -1)) % len(choices) elif key in ('Z','RETURN'): self.commit(choices[self.selection]) elif key == 'SPACE': self.commit('wait') elif STAGE >= 2 and key == 'TAB': self.commit('cancel' if self.state.charging else 'down' if self.state.floor == 'upper' else 'up') def control(self, pressed, held): if self.gated: if not pressed and not held: self.gated = False return if len(pressed | held) > 1: self.gated = True self.message = 'CHORD REJECTED / RELEASE ALL KEYS' elif len(pressed) == 1: self.press(next(iter(pressed))) self.gated = True def view(self): s = self.state if STAGE >= 4: return public(self.board, s, self.compare) if STAGE == 3: # Stage 03 uses the stored observation. The radio/compare projection is added in 04. return {'t':s.t,'floor':s.floor,'light':s.light,'battery':s.battery,'charging':s.charging, 'seen_at':s.seen_at,'known':s.seen,'radio':(), 'spawn_schedule':tuple(x[0] for x in self.board.ships), 'arrived':sum(sh.status=='arrived' for sh in s.ships),'result':s.result} return {'t':s.t,'floor':s.floor if STAGE == 2 else 'upper','light':s.light, 'battery':s.battery,'charging':s.charging if STAGE == 2 else False, 'seen_at':0,'known':((0,SHIP_LANE,DUE,'arrived' if s.result=='success' else 'missed' if s.result=='missed' else 'pending'),), 'radio':(),'spawn_schedule':(0,),'arrived':int(s.result=='success'),'result':s.result} def draw(self, p): v = self.view() p.cls(1) p.text(14,12,'LIGHTHOUSE SHIFT / STAGE ' + str(STAGE) + (' / BOARD ' + self.board.id if STAGE >= 3 else ' / KNOWN LAB'),10) p.text(14,28,'FICTIONAL ROUTES / ONE CONFIRMED ACTION = ONE TICK',6) p.text(14,48,'TIME ' + str(v['t']) + ' / FLOOR ' + v['floor'].upper() + ' / LIGHT ' + v['light'] + ' / BATTERY ' + str(v['battery']) + '/4',7) p.rect(12,68,624,180,0) if v['floor'] == 'upper' or STAGE >= 4 and self.compare: for i,lane in enumerate(LANES): x = 118 + i * 204 p.line(x,98,x,205,5) if lane == v['light']: p.tri(322,234,x-40,94,x+40,94,4) p.text(x-3,82,lane,10) for ship_id,known_lane,due,status in v['known']: if known_lane == lane and status == 'pending': y = 108 + min(5,ship_id) * 18 p.rect(x-6,y,12,6,7);p.text(x+13,y,str(ship_id+1)+' DUE '+str(due),10) p.rect(311,216,22,26,7) else: p.text(28,86,'DOWNSTAIRS / BEAM KEEPS POINTING ' + v['light'],10) p.text(28,107,'KNOWN LAB: SCHEDULE REMAINS PUBLIC' if STAGE == 2 else 'NO CURRENT SEA VIEW / THE NOTEBOOK BELOW IS OLD',6) for i in range(4): p.rect(136+i*94,139,70,68,11 if i= 4 and self.compare else ''),10) for j,(i,lane,due,status) in enumerate(v['known']): p.text(14,283+j*20,'SHIP '+str(i+1)+' / '+lane+' / DUE '+str(due)+' / LEFT '+str(due-v['t'])+' / OBSERVED '+status.upper(),7) if STAGE >= 3: p.text(14,392,'PUBLIC SPAWN TIMES ' + str(v['spawn_schedule']) + ' / ARRIVED ' + str(v['arrived']),6) if STAGE >= 4: p.text(14,410,'UNSEEN IDS ' + str(tuple(i+1 for i in v['unseen'])),8) radio = [(i+1,lane,due,status) for i,lane,due,status in v['radio']] p.text(14,428,'RADIO RETRIES: '+str(radio),11) else: p.text(14,410,'NO RETRIES IN THIS LAB' if STAGE < 3 else 'RETRY RULE ACTIVE / RADIO DISPLAY IS ADDED IN STAGE 04',6) opts = legal(self.state) p.text(14,457,'OPTIONS: '+' / '.join(('['+a.upper()+']') if i==self.selection else a.upper() for i,a in enumerate(opts)),10) if v['result']: p.text(14,478,'RESULT '+v['result'].upper()+(' / LAB ENDED / R RESTART' if STAGE < 3 else ' / OUTER HARBOR IS SAFE / R RESTART'),11 if v['result']=='success' else 8) else: p.text(14,478,'ONLY AN ARRIVAL USES BATTERY / TURNING COSTS ONE TICK' if STAGE == 1 else 'FINISH ADDS BATTERY BEFORE ARRIVAL CHECKS',6) p.text(14,503,self.message,10) p.text(14,526,'LEFT/RIGHT SELECT / Z OR ENTER CONFIRM / SPACE WAIT / R RESET / ESC QUIT',7) if STAGE >= 2: p.text(14,543,'TAB STAIRS (CANCEL WHEN CHARGING) / RELEASE ALL KEYS',6) if STAGE >= 3: p.text(14,560,'1-3 BOARD' + (' / B DISPLAY ONLY' if STAGE >= 4 else '') + (' / X UNDO ONE TICK' if STAGE == 5 else ''),6) def demo_keys(): if STAGE == 1: return ('RIGHT','Z','SPACE','SPACE') if STAGE == 2: return ('TAB','Z','Z') return ('SPACE','SPACE','RIGHT','Z','TAB','Z','Z') def main(headless=False,screenshot=None,state_json=None,demo=False): import pyxel as p import json from pathlib import Path p.init(648,584,title='Lighthouse lesson '+str(STAGE),fps=30,display_scale=1,headless=headless) app=App();sequence=demo_keys() if demo else ();keynames=('LEFT','RIGHT','Z','RETURN','SPACE','TAB','R','B','X','1','2','3') keys={k:getattr(p,'KEY_'+k) for k in keynames};limit=max(8,len(sequence)*2+6) def update(): if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit: if screenshot: app.draw(p);p.screen.save(screenshot,1) if state_json: Path(state_json).write_text(json.dumps({'stage':STAGE,'public':app.view(),'scope':'Actual Linux Pyxel synthetic controls, no human playtest'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8') p.quit() if headless: i=p.frame_count//2;pressed={sequence[i]} if demo and p.frame_count%2==0 and i