# 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. """059 stage 1: simplified teaching UI; see LESSON.md.""" STAGE = 1 from dataclasses import dataclass, asdict, replace @dataclass(frozen=True) class Level: seed:str title:str edges:tuple deadlines:tuple end:int LEVELS=( Level('A-01-v1','先に着く一刻',(('H','J',2,1),('J','A',2,2),('J','B',3,1),('H','K',3,1),('K','B',2,1),('J','K',1,1)),(9,25),38), Level('B-01-v1','近い人を後で',(('H','J',1,1),('J','A',2,1),('A','K',1,1),('K','B',1,2),('K','H',2,2)),(14,16),38), Level('C-01-v1','二度使う幹線',(('H','J',3,2),('J','A',1,1),('J','B',1,2),('H','K',2,1),('K','A',2,2),('K','B',2,1)),(14,28),44)) @dataclass(frozen=True) class State: node:str='H' t:int=0 prepared:int=0 people:tuple=(0,0) # 0 untouched,1 contacted,2 carried,3 delivered,4 missed carrier:int=-1 tick:int=0 outcome:str='running' def options(s,l):return [(i,e[1] if e[0]==s.node else e[0]) for i,e in enumerate(l.edges) if s.node in e[:2]] def cost(s,l,i,prep=False,variant=False): _,_,d,snow=l.edges[i] if s.prepared>>i&1:return d*(snow+2) if s.carrier>=0 and variant else d return d*(snow+2 if s.carrier>=0 else snow+1 if prep else snow) # Teaching UI: ASCII, no audio, one complete key release between decisions. START_DISTANCE = 2 START_SNOW = 2 START_LEVEL = 0 LAB_MODES = ('WALK', 'PREPARE', 'CARRY') POS = {'H': (55, 198), 'J': (155, 95), 'A': (276, 65), 'K': (178, 263), 'B': (279, 226)} PEOPLE = ('UNCONTACTED', 'CONTACTED', 'ON STRETCHER', 'DELIVERED', 'MISSED') class App: def __init__(self): self.level_index = START_LEVEL self.previous = set() self.gate = False self.reset() def reset(self): self.timeline = Timeline() if STAGE >= 4 else None self.simple_state = State() self.selected = 0 self.preparing = False self.reason = 'ready' self.distance = START_DISTANCE self.snow = START_SNOW self.mode = 0 self.prepared_lab = False @property def state(self): return self.timeline.state if STAGE >= 4 else self.simple_state @property def level(self): return LEVELS[self.level_index] def choices(self): state = self.state rows = [] if STAGE >= 4 and state.node in ('A', 'B') and state.people['AB'.index(state.node)] == 1 and state.carrier < 0: rows.append(('rescue', 'START RESCUE (+1 TICK)')) for edge, destination in options(state, self.level): action = f'move:{edge}:' + ('prep' if self.preparing else 'walk') rows.append((action, ('PREP ' if self.preparing else 'WALK ') + destination)) if STAGE >= 4 and state.node == 'H': rows.append(('finish', 'FINISH AT HOME')) return rows def action(self, action): if STAGE >= 4: if action == 'undo' and STAGE < 5: return self.timeline, self.reason = step(self.timeline, self.level, action) elif action.startswith('move:'): _, edge, mode = action.split(':') self.simple_state, self.reason = walk(self.simple_state, self.level, int(edge), mode == 'prep') self.selected = 0 def laboratory_cost(self): level = Level('LAB', 'Cost laboratory', (('H', 'A', self.distance, self.snow),), (9, 25), 38) state = State(prepared=int(self.prepared_lab), carrier=0 if self.mode == 2 else -1) return cost(state, level, 0, self.mode == 1) def control(self, down): down = set(down) fresh = down - self.previous self.previous = down if self.gate: if not down: self.gate = False return if not fresh: return if len(down) != 1: self.gate = True return key = next(iter(fresh)) self.gate = True if key == 'R': self.reset() return if STAGE == 1: if key == 'UP': self.distance = min(4, self.distance+1) elif key == 'DOWN': self.distance = max(1, self.distance-1) elif key == 'RIGHT': self.mode = (self.mode+1) % 3 elif key == 'LEFT': self.mode = (self.mode-1) % 3 elif key == 'SPACE': self.snow = 3-self.snow elif key == 'TAB': self.prepared_lab = not self.prepared_lab return if STAGE >= 4 and key in ('1', '2', '3'): self.level_index = int(key)-1 self.reset() return if STAGE >= 5 and key == 'X': self.action('undo') return if self.state.outcome != 'running': return if key in ('UP', 'LEFT'): self.selected = (self.selected-1) % len(self.choices()) elif key in ('DOWN', 'RIGHT'): self.selected = (self.selected+1) % len(self.choices()) elif key == 'SPACE' and STAGE >= 3: self.preparing = not self.preparing elif key in ('Z', 'RETURN'): self.action(self.choices()[self.selected][0]) def snapshot(self): return {'stage': STAGE, 'level': self.level_index, 'state': asdict(self.state), 'selected': self.selected, 'preparing': self.preparing, 'reason': self.reason, 'lab': [self.distance, self.snow, self.mode, self.prepared_lab, self.laboratory_cost()]} def draw(self, p): p.cls(1) p.text(14, 10, f'059 SNOW ROUTES / STEP {STAGE}', 10) p.text(14, 26, 'A fictional route model, not real rescue instructions.', 13) if STAGE == 1: p.text(32, 65, 'EDGE COST LABORATORY', 11) p.text(32, 94, f'DISTANCE {self.distance} SNOW {self.snow}', 7) p.text(32, 124, f'MODE {LAB_MODES[self.mode]} PREPARED {self.prepared_lab}', 7) p.text(32, 168, f'TRAVEL COST: {self.laboratory_cost()} TICKS', 10) p.text(32, 214, 'Unprepared: walk d*s / prepare d*(s+1) / carry d*(s+2)', 6) p.text(32, 238, 'Prepared: all travel costs d. This is a formula experiment.', 6) p.text(32, 278, 'Preparation is a paid journey in the actual game.', 9) p.text(32, 360, 'UP/DOWN distance LEFT/RIGHT mode SPACE snow TAB prepared', 7) else: s = self.state p.text(14, 44, f'MAP {self.level.seed} CLOCK {s.t}/{self.level.end} {s.outcome.upper()}', 11) for edge, (u, v, distance, snow) in enumerate(self.level.edges): x,y=POS[u];xx,yy=POS[v] color=11 if s.prepared >> edge & 1 else 5 p.line(x,y,xx,yy,color) mx,my=(x+xx)//2,(y+yy)//2 if self.level_index == 2 and edge in (2, 4): mx,my=round(x+(xx-x)*0.72),round(y+(yy-y)*0.72) p.rect(mx-22,my-6,48,12,1) p.text(mx-20,my-4,f'{edge}: {distance}/{snow}',color) for name,(x,y) in POS.items(): p.circ(x,y,12,3 if name == s.node else 2) p.circb(x,y,12,10 if name == s.node else 6) p.text(x-2,y-3,name,7) p.text(14, 299, 'Edge labels: index / distance / snow.', 13) p.text(14, 314, 'Line length on screen is NOT edge distance.', 6) for i in range(2): p.text(352, 75+i*28, f'{"AB"[i]}: {PEOPLE[s.people[i]]}', 7) p.text(352, 88+i*28, f'CONTACT BY {self.level.deadlines[i]}', 13) p.text(352, 135, 'MODE: PREPARE' if self.preparing else 'MODE: WALK', 11) rows = self.choices() for i,(action,label) in enumerate(rows): y=164+i*22 if i == self.selected: p.rect(344,y-3,280,17,3) p.text(352,y,('> ' if i == self.selected else ' ')+label,7) if STAGE >= 5: forecast_state, reason = move(s, self.level, rows[self.selected][0]) p.text(352, 310, f'NEXT CLOCK {forecast_state.t} {reason.upper()}', 10) p.text(352, 326, f'NEXT PEOPLE {forecast_state.people}', 6) p.text(352, 342, f'HISTORY {len(self.timeline.history)} PREVIEW ONLY', 13) p.text(14, 363, 'Reason: '+self.reason, 10) p.text(14, 385, 'Arrows select Z/ENTER commit R reset', 7) p.text(14, 401, ('SPACE walk/prep ' if STAGE >= 3 else 'Walking/contact only. No completed rescue yet. ')+('1/2/3 maps' if STAGE >= 4 else ''), 6) if STAGE >= 5: p.text(14, 417, 'X undo all model state. Preview does not advance time.', 13) p.text(14, 440, 'Release all keys between decisions. ESC exits. Save / quit / rerun edits.', 6) def demo_keys(): if STAGE == 1: return ['RIGHT', 'RIGHT', 'TAB'] if STAGE == 2: return ['Z', 'DOWN', 'Z'] if STAGE == 3: return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z'] return ['SPACE', 'Z', 'SPACE', 'DOWN', 'Z', 'Z', 'Z', 'Z'] def main(): import argparse from pathlib import Path parser = argparse.ArgumentParser(description='Standalone teaching stage; no files are overwritten.') parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--demo-checkpoint', action='store_true') parser.add_argument('--output', type=Path) parser.add_argument('--state-json', type=Path) args = parser.parse_args() if (args.demo_checkpoint or args.output is not None or args.state_json is not None) and not args.headless_smoke: parser.error('Use --headless-smoke with demo/output options.') for name,suffix in [('output','.png'),('state_json','.json')]: path=getattr(args,name) if path is None: continue path=path.absolute() if path.suffix.lower()!=suffix or path.exists() or path.is_symlink() or not path.parent.is_dir(): parser.error(f'{name} needs a new {suffix} file under an existing directory.') setattr(args,name,path) import pyxel app=App() pyxel.init(640,456,title=f'059 Snow Routes - {STAGE}',fps=30,display_scale=1,headless=args.headless_smoke,capture_sec=0) keys=('UP','DOWN','LEFT','RIGHT','SPACE','TAB','Z','X','R','RETURN','1','2','3') frames=0 demo=demo_keys() if args.demo_checkpoint else [] stop_frame=2*len(demo)+3 def update(): nonlocal frames if args.headless_smoke: current=frames//2 down={demo[current]} if frames%2==0 and current=stop_frame: if args.output is not None: pyxel.screenshot(str(args.output),scale=1) if args.state_json is not None: import json with args.state_json.open('x',encoding='utf-8') as f: json.dump(app.snapshot(),f,ensure_ascii=False,indent=2) pyxel.quit() pyxel.run(update,draw) if __name__ == '__main__': main()