# -*- coding: utf-8 -*- # 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. STAGE = 2 from dataclasses import dataclass, replace, asdict from collections import deque PERMANENT = ((0,3),(3,4),(4,5),(5,2),(2,1),(1,0)) SHOP_POS = (1,4,2) @dataclass(frozen=True) class Guest: pos: int wish: int status: str = 'pending' exited: bool = False guided: bool = False @dataclass(frozen=True) class State: t: int = 0 guests: tuple = () phase: str = 'play' def new(): return State(guests=(Guest(5,0),Guest(3,1),Guest(2,2))) def route(s, start, target): if start == target: return () graph = {i: [] for i in range(6)} for a, b in PERMANENT: graph[a].append(b) graph[b].append(a) dist = {target: 0} queue = deque([target]) while queue: p = queue.popleft() for n in graph[p]: if n not in dist: dist[n] = dist[p] + 1 queue.append(n) result, p = [], start while p != target: p = min(n for n in graph[p] if dist[n] == dist[p] - 1) result.append(p) return tuple(result) def goal(g): return SHOP_POS[g.wish] if g.status == 'pending' else 0 def legal(s): if s.phase != 'play': return () return tuple('guide:' + str(i) for i, g in enumerate(s.guests) if not g.exited) + ('wait',) def step(s, action): if action not in legal(s): return s guide = int(action[-1]) if action.startswith('guide:') else -1 guests = [] for i, old in enumerate(s.guests): if old.exited: guests.append(old) continue g = replace(old, guided=old.guided or guide == i) target = goal(g) path = route(s, g.pos, target) steps = path[:2 if guide == i else 1] if steps: g = replace(g, pos=steps[-1]) if g.status == 'pending' and g.pos == target: g = replace(g, status='bought') elif g.status != 'pending' and g.pos == 0: g = replace(g, exited=True) guests.append(g) t = s.t + 1 return replace(s, t=t, guests=tuple(guests), phase='done' if t == 6 else 'play') KEYS = {'RETURN','LEFT','RIGHT','UP','DOWN','TAB','SPACE','X','R','ESCAPE'} class Controller: def __init__(self, path=None): self.s = new() self.mode = 0 if STAGE in (1,3) else 1 self.target = 0 self.previous = set() self.gate = False self.dialog = '' self.title = False self.help = False self.detail = False self.auto = False self.history = [] self.log = [] self.notice = '' def selection(self): if STAGE == 1: return self.target return 'close:' + str(self.target) if self.mode == 0 else 'guide:' + str(self.target) if self.mode == 1 else 'wait' def apply(self, action): old = self.s self.s = step(old, action) if self.s == old: return 'ignored' self.history.append(old) return 'commit' def feed(self, down): down = set(down) & KEYS fresh = down - self.previous self.previous = down if self.gate: if not down: self.gate = False return None if not fresh: return None self.gate = True if len(down) > 1: return 'reject:chord' key = next(iter(fresh)) if self.dialog: if key in ('X','ESCAPE'): self.dialog = '' return 'cancel' if key == 'RETURN': dialog, self.dialog = self.dialog, '' if dialog == 'quit': return 'quit' self.s = new() self.history = [] return 'reset' return 'ignored:dialog' if key == 'ESCAPE': self.dialog = 'quit' return 'confirm:quit' if key == 'R': self.dialog = 'retry' return 'confirm:retry' if getattr(self.s,'phase','play') == 'walkout': return self.apply('walk') if key == 'RETURN' else 'ignored:walkout' if getattr(self.s,'phase','play') == 'done': return 'ignored:done' if key in ('LEFT','UP','RIGHT','DOWN'): self.target = (self.target + (1 if key in ('RIGHT','DOWN') else -1)) % (6 if STAGE == 1 else 3) return 'select:target' if STAGE >= 2 and key == 'TAB': self.mode = (1 if self.mode == 2 else 2) if STAGE == 2 else (self.mode + 1) % 3 return 'select:mode' if STAGE >= 2 and key == 'SPACE': self.mode = 2 return 'select:wait' if key == 'RETURN': return self.apply(self.selection()) return 'ignored:key' import argparse import json from pathlib import Path POINTS = ((45,96),(166,96),(287,96),(45,232),(166,232),(287,232)) COLORS = (10,12,14) SHOP_LABELS = ('TEA','MASK','FIREWORK') def tap(ui, key): event = ui.feed({key}) ui.feed(set()) return event def choose(ui, action): if STAGE == 1: while ui.target != action: tap(ui,'RIGHT') elif action == 'walk': pass elif action == 'wait': tap(ui,'SPACE') else: mode = 0 if action.startswith('close:') else 1 while ui.mode != mode: tap(ui,'TAB') target = int(action[-1]) while ui.target != target: tap(ui,'RIGHT') def commit(ui, action): choose(ui, action) return tap(ui,'RETURN') def demo(ui, scenario): if scenario == 'initial': return if ui.title: if scenario in ('case_b','case_c','fixed'): tap(ui,{'case_b':'2','case_c':'3','fixed':'B'}[scenario]) tap(ui,'RETURN') if scenario in ('case_b','case_c','fixed') and STAGE >= 4: commit(ui,'close:0' if scenario == 'fixed' else 'wait') return if scenario == 'finish_practice' and STAGE == 2: for _ in range(6):commit(ui,'wait') return if scenario == 'loaded' and STAGE == 5: tap(ui,'L') return if scenario == 'checkpoint': commit(ui,0 if STAGE == 1 else 'guide:0' if STAGE == 2 else 'close:0') elif scenario == 'confirm': commit(ui,0 if STAGE == 1 else 'wait');tap(ui,'R') elif scenario == 'arrived' and STAGE == 1: for _ in range(3):commit(ui,0) elif scenario == 'guide' and STAGE >= 2: commit(ui,'guide:0') elif scenario == 'preview' and STAGE >= 2: choose(ui,'guide:0') elif scenario in ('closed','invalid_close') and STAGE >= 3: commit(ui,'close:2') if scenario == 'invalid_close':commit(ui,'close:2') elif scenario in ('success','cleanup','sixth','walkout','undo','saved','loaded','auto_help','auto_detail','auto_done') and STAGE >= 3: if scenario in ('walkout','auto_help','auto_detail','auto_done'): # Explicit constructed boundary, not presented as a reachable natural campaign. ui.s = replace(ui.s,t=5,guests=(Guest(5,0),Guest(4,2),Guest(3,2))) commit(ui,'wait') elif scenario == 'cleanup': for _ in range(6):commit(ui,'wait') else: for action in ('guide:0','close:0','close:1','close:2','wait','wait')[:5 if scenario=='sixth' else 6]: commit(ui,action) if scenario in ('success','cleanup','saved','loaded'): while ui.s.phase == 'walkout':commit(ui,'walk') if scenario == 'undo':tap(ui,'X') if scenario in ('saved','loaded'):tap(ui,'S' if scenario=='saved' else 'L') if scenario.startswith('auto_'): tap(ui,'SPACE') if scenario == 'auto_help':tap(ui,'H') if scenario == 'auto_detail':tap(ui,'Z') elif scenario == 'detail' and STAGE >= 4: tap(ui,'Z') else: raise ValueError('scenario unavailable') def parse_args(argv=None): p = argparse.ArgumentParser(description='Single-file Pyxel lesson 094') p.add_argument('--headless-smoke',action='store_true') p.add_argument('--demo-checkpoint',action='store_true') scenarios=['initial','checkpoint','confirm']+(['arrived'] if STAGE==1 else ['guide','preview']) if STAGE==2:scenarios+=['finish_practice'] if STAGE>=3:scenarios+=['closed','invalid_close','success','cleanup','sixth','walkout'] if STAGE>=4:scenarios+=['undo','detail','auto_help','auto_detail','auto_done','case_b','case_c','fixed'] if STAGE==5:scenarios+=['saved','loaded'] p.add_argument('--scenario',choices=scenarios) p.add_argument('--screenshot',type=Path) p.add_argument('--state-json',type=Path) p.add_argument('--save-file',type=Path,default=Path('save_094.json')) a=p.parse_args(argv) if not a.headless_smoke and (a.demo_checkpoint or a.scenario or a.screenshot or a.state_json):p.error('diagnostics require --headless-smoke') if a.demo_checkpoint and a.scenario:p.error('choose checkpoint or scenario') for name,suffix in (('screenshot','.png'),('state_json','.json')): path=getattr(a,name) if path is None:continue path=path.resolve() if path.suffix.lower()!=suffix or not path.parent.is_dir() or path.exists():p.error(name+': require a new file of correct type in an existing directory') setattr(a,name,path) a.save_file=a.save_file.resolve() if a.save_file.suffix.lower()!='.json' or not a.save_file.parent.is_dir() or a.save_file.is_dir():p.error('save-file: require .json in an existing directory') if a.save_file in (a.screenshot,a.state_json):p.error('save-file and diagnostic output must differ') if a.scenario=='saved' and a.save_file.exists():p.error('saved scenario requires a new save file') return a class App: def __init__(self,args): self.args=args;self.ui=Controller(args.save_file);self.frame=0;self.auto_clock=0 if args.headless_smoke:demo(self.ui,args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial')) self.capture_frame=182 if args.scenario in ('auto_done','auto_help','auto_detail') else 2 pyxel.init(600,420,title='094 FESTIVAL / STAGE '+str(STAGE),fps=30,quit_key=pyxel.KEY_NONE,headless=args.headless_smoke,display_scale=1) self.keys={key:getattr(pyxel,'KEY_'+key) for key in KEYS} pyxel.run(self.update,self.draw) def advance_auto(self): u=self.ui if u.auto and getattr(u.s,'phase','play')=='walkout' and not (u.title or u.help or u.detail or u.dialog or u.gate): self.auto_clock+=1 if self.auto_clock>=30: self.auto_clock=0;u.apply('walk') else:self.auto_clock=0 def update(self): self.frame+=1 if self.args.headless_smoke: self.advance_auto() if self.frame>self.capture_frame:pyxel.quit() return event=self.ui.feed({key for key,code in self.keys.items() if pyxel.btn(code)}) if event=='quit':pyxel.quit() self.advance_auto() def draw(self): u,s=self.ui,self.ui.s;pyxel.cls(1) pyxel.text(12,10,'094 FESTIVAL AFTERGLOW | STAGE '+str(STAGE),10) pyxel.text(12,25,'TICK: '+str(s.t)+' PHASE: '+getattr(s,'phase','walk').upper(),7) edge_list=edges(s) if STAGE>=3 else PERMANENT for a,b in edge_list: x,y=POINTS[a];xx,yy=POINTS[b] pyxel.line(x,y,xx,yy,11 if (a,b) not in PERMANENT else 5) for i,(x,y) in enumerate(POINTS): pyxel.circ(x,y,15,5);pyxel.circb(x,y,15,6) # Solid label background prevents graph edges crossing text. pyxel.rect(x-20,y+21,62,10,1) pyxel.text(x-19,y+23,str(i)+' '+('EXIT','TEA','FIREWORK','BRIDGE','MASK','STAGE')[i],7) guests=() if STAGE==1 else s.guests if STAGE==1: x,y=POINTS[s.pos];pyxel.circ(x,y,5,10) else: for i,g in enumerate(guests): x,y=POINTS[g.pos];x+=(i-1)*9 if g.exited:pyxel.circb(x,y,4,COLORS[i]) else:pyxel.circ(x,y,4,COLORS[i]) pyxel.rect(345,48,243,228,2) if STAGE==1: pyxel.text(355,60,'POSITION: '+str(s.pos)+' TARGET: '+str(u.target),10) pyxel.text(355,80,'PATH: '+str(route(s,s.pos,u.target)),7) pyxel.text(355,104,'Shortest path; small next node wins ties.',6) else: for i,g in enumerate(guests): y=60+i*48 pyxel.text(355,y,'G'+str(i)+' POS '+str(g.pos)+' WISH '+SHOP_LABELS[g.wish],COLORS[i]) pyxel.text(355,y+13,g.status.upper()+(' / HOME' if g.exited else ''),7) pyxel.text(355,y+26,'PATH '+str(route(s,g.pos,goal(g))),6) if STAGE>=3: pyxel.text(355,213,'OPEN: '+str(tuple(int(v) for v in s.opened)),11) pyxel.text(355,229,'SALES: '+str(s.sales)+' SELF: '+str(s.closed_self),7) pyxel.text(355,245,'CLEANUP: '+str(int(s.cleanup))+' WALK: '+str(s.walk_t),7) phase=getattr(s,'phase','play') pyxel.rect(12,286,576,42,2) if phase=='play': selected=u.selection();preview=step(s,selected) pyxel.text(20,295,'SELECT: '+str(selected)+' PREVIEW ONLY',10) if STAGE==1:message='NEXT POS '+str(preview.pos) elif preview==s:message='INVALID / no time or history change' else:message='NEXT POS '+str(tuple(g.pos for g in preview.guests))+' NEXT TICK '+str(preview.t) pyxel.text(20,312,message,6) elif phase=='walkout': pyxel.text(20,295,'SIX SLOTS ENDED / WALKOUT ONLY',10) pyxel.text(20,312,'ENTER: next step SPACE: auto (stage 4+) / no more sales',7) else: achieved=complete(s) if STAGE>=3 else all(g.exited for g in s.guests) pyxel.text(20,295,'RESULT: '+('SELF-CLOSED + ALL HOME' if achieved and STAGE>=3 else 'ALL HOME' if achieved else 'CLEANUP HELP / REQUEST NOT COMPLETE'),11) pyxel.text(20,312,'Result is not a single moral or enjoyment score.',7) pyxel.text(12,342,'Arrows: target ENTER: commit one step R: confirm restart',7) if STAGE>=2:pyxel.text(12,358,'TAB: mode SPACE: select wait Release ALL keys between presses',6) if STAGE>=4:pyxel.text(12,374,'X: Undo Z: detail H/P: help M: menu Title 1/2/3 + B',6) if STAGE==5:pyxel.text(12,390,'S/L: save/load current state; UTF-8 JSON in caller folder',6) pyxel.text(12,407,'ESC: confirm quit. R never reloads edited Python code.',7) if u.title or u.help: pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,10) pyxel.text(50,83,'SIX SLOTS: CLOSE THREE STALLS AND SEE EVERYONE HOME',10) pyxel.text(50,110,'TAB: close / guide / wait. Arrows: target. ENTER commits.',7) pyxel.text(50,132,'Each guest keeps their own wish. Guiding means two edges.',7) pyxel.text(50,154,'Closing happens before walking and buying. Sales count open stalls.',7) pyxel.text(50,176,'Case '+s.case+' Variant '+s.variant+' / 1,2,3 case; B variant',11) pyxel.text(50,200,'ENTER starts. H/P closes help. Release all keys first.',10) pyxel.text(50,223,'After six slots: only walkout; cleanup is not self-closure.',6) if u.detail: g=s.guests[u.target];pyxel.rect(35,65,530,203,1);pyxel.rectb(35,65,530,203,11) pyxel.text(50,83,'PUBLIC GUEST FACTS / G'+str(u.target),10) pyxel.text(50,110,'WISH '+SHOP_LABELS[g.wish]+' POS '+str(g.pos)+' '+g.status.upper(),7) pyxel.text(50,132,'GUIDED '+str(g.guided)+' SHORTCUT BITS '+str(g.shortcuts),7) pyxel.text(50,154,'EXIT TICK '+str(g.exit_t)+' LAST SELF-CLOSED '+str(s.last),7) pyxel.text(50,178,'Japanese farewell is retained in state JSON and source.',6) pyxel.text(50,220,'Z/X closes detail. World and auto playback are paused.',10) if u.dialog: pyxel.rect(35,110,530,114,1);pyxel.rectb(35,110,530,114,8) pyxel.text(50,133,'CONFIRM '+u.dialog.upper(),10) pyxel.text(50,158,'Release ALL keys. ENTER confirms; X cancels.',7) pyxel.text(50,183,'Restart resets this evening, leaving saved files untouched.',6) if u.notice: msg='SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: original file preserved' pyxel.rect(345,30,243,11,1);pyxel.text(350,32,msg,10) if self.args.headless_smoke and self.frame==self.capture_frame: if self.args.screenshot:pyxel.screenshot(str(self.args.screenshot),scale=1) if self.args.state_json: data={'stage':STAGE,'state':asdict(s),'mode':u.mode,'target':u.target,'title':u.title,'dialog':u.dialog,'detail':u.detail,'help':u.help,'auto':u.auto,'history':[asdict(x) for x in u.history],'scope':'Actual Pyxel Linux headless; synthetic input; walkout scenarios use an explicitly constructed boundary'} with self.args.state_json.open('x',encoding='utf-8') as f:json.dump(data,f,ensure_ascii=False,indent=2) if __name__=='__main__': args=parse_args() import pyxel App(args)