# 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 交代灯台守: 教材段階 3。""" STAGE = 3 from dataclasses import dataclass, replace, asdict VERSION='081.1.0' LANES=('L','M','R') @dataclass(frozen=True) class Board: id:str;title:str;battery:int;light:str;ships:tuple BOARDS=(Board('A','晴れた四便',1,'L',((0,2,'L'),(2,6,'M'),(5,10,'R'),(9,14,'L'))),Board('B','同じ航路の列',1,'M',((0,3,'M'),(1,5,'M'),(5,9,'L'),(8,13,'R'),(12,17,'R'))),Board('C','戻って見る理由',2,'L',((0,3,'L'),(3,7,'R'),(6,11,'M'),(9,15,'L'),(13,19,'M')))) @dataclass(frozen=True) class Ship: due:int;status:str='pending';retry:int=0;first:bool=False @dataclass(frozen=True) class State: t:int;floor:str;light:str;battery:int;charging:bool;ships:tuple;seen:tuple;seen_at:int;stairs:int=0;charges:int=0;events:tuple=();result:str='' def observe(board,s): seen=tuple((i,lane,sh.due,sh.status) for i,((spawn,_,lane),sh) in enumerate(zip(board.ships,s.ships)) if spawn<=s.t) return replace(s,seen=seen,seen_at=s.t) def new(board):return observe(board,State(0,'upper',board.light,board.battery,False,tuple(Ship(d) for _,d,_ in board.ships),(),0)) def legal(s): if s.result:return () if s.charging:return ('finish','cancel') return ('L','M','R','down','wait') if s.floor=='upper' else ('charge','up','wait') def step(board,s,a): if a not in legal(s):return s f=s.floor;l=s.light;b=s.battery;c=s.charging;stairs=s.stairs;charges=s.charges;e=[] if a in LANES:l=a elif a in ('down','up','cancel'):f='lower' if a=='down' else 'upper';c=False;stairs+=1 elif a=='charge':c=True;e.append('充電開始: 次の仕上げで+3') elif a=='finish':b=min(4,b+3);c=False;charges+=1;e.append('充電完了: 電池+3 / 上限4') t=s.t+1;ships=[] for i,((spawn,due,lane),sh) in enumerate(zip(board.ships,s.ships)): if sh.status=='pending' and sh.due==t: if l==lane and b>=1:b-=1;sh=replace(sh,status='arrived',first=not sh.retry);e.append(f'{i+1}便 案内完了 / {lane}') elif not sh.retry:sh=replace(sh,due=t+4,retry=1);e.append(f'{i+1}便 外港へ / {lane} 刻{t+4}に再通過') else:sh=replace(sh,status='outer');e.append(f'{i+1}便 外港で待機 / 再挑戦できます') ships.append(sh) result='success' if all(sh.status=='arrived' for sh in ships) else ('unfinished' if t>=max(d for _,d,_ in board.ships)+4 else '') out=State(t,f,l,b,c,tuple(ships),s.seen,s.seen_at,stairs,charges,tuple(e),result) return observe(board,out) if f=='upper' else out # 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