# -*- 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 = 1 from dataclasses import dataclass, replace, asdict START = 14 FLOOR = frozenset(p for p in range(35) if p % 7 in (0, 6) or p // 7 in (0, 2, 4)) ACTIONS = ('up', 'down', 'left', 'right') @dataclass(frozen=True) class State: pos: int = START t: int = 0 def new(): return State() def step(s, action): delta = {'up': (0, -1), 'down': (0, 1), 'left': (-1, 0), 'right': (1, 0)}.get(action) if delta is None: return s x, y = s.pos % 7 + delta[0], s.pos // 7 + delta[1] q = y * 7 + x if not (0 <= x < 7 and 0 <= y < 5 and q in FLOOR): return s return replace(s, pos=q, t=s.t + 1) KEYS = {'UP','DOWN','LEFT','RIGHT','RETURN','SPACE','Z','X','R','ESCAPE'} class Controller: def __init__(self, path=None): self.s = new() self.selected = 'right' self.previous = set() self.gate = False self.dialog = '' self.history = [] self.log = [] self.notice = '' self.title = False self.help = False def apply(self, action): if action == 'undo': if not self.history: return 'ignored:undo' self.s = self.history.pop() return 'undo' old = self.s self.s = step(old, action) if self.s == old: return 'ignored:' + action self.history.append(old) return action 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' if STAGE == 1: self.s = new() return 'reset' return self.apply('retry') return 'ignored:dialog' if key == 'ESCAPE': self.dialog = 'quit' return 'confirm:quit' if key == 'R' and getattr(self.s, 'phase', 'play') == 'play': self.dialog = 'retry' return 'confirm:retry' if key == 'X' and STAGE >= 3: return self.apply('undo') action = {'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(key) if action in ACTIONS: self.selected = action return 'select:' + action if key == 'RETURN': return self.apply(self.selected) return 'ignored:key' # UI and diagnostic CLI: independent of the pure model. import argparse import json from pathlib import Path def tap(ui, key): event = ui.feed({key}) ui.feed(set()) # Every distinct key press has a full release. return event def commit(ui, action): if action == 'next': return tap(ui, 'RETURN') key = {'up':'UP','down':'DOWN','left':'LEFT','right':'RIGHT','wait':'SPACE','use':'Z'}[action] tap(ui, key) return tap(ui, 'RETURN') def demo(ui, scenario): if scenario == 'initial': return if ui.title: tap(ui, 'RETURN') if scenario == 'checkpoint': commit(ui, 'right') return if scenario == 'wall': commit(ui, 'right'); commit(ui, 'up') elif scenario in ('caught', 'preview') and STAGE >= 2: if STAGE >= 4: ui.s = new('A') ui.s = replace(ui.s, guards=(0, 1, 0)) commit(ui, 'right'); commit(ui, 'right'); commit(ui, 'wait') tap(ui, 'RIGHT') if scenario == 'caught': tap(ui, 'RETURN') elif scenario == 'timeout' and STAGE >= 2: for _ in range(40): commit(ui, 'wait') elif scenario == 'confirm': commit(ui, 'right'); tap(ui, 'R') elif scenario in ('returned', 'next', 'undo_next', 'success', 'saved', 'loaded', 'return40') and STAGE >= 3: nights = 3 if scenario == 'success' and STAGE >= 4 else 1 for night in range(nights): if night: commit(ui, 'next') # A deterministic public model route, waiting before occupied gates. for direction in ('right', 'left'): for _ in range(6): q = ui.s.pos + (1 if direction == 'right' else -1) while q in GATES and occupied(ui.s.guards[GATES.index(q)], ui.s.t + 1): commit(ui, 'wait') commit(ui, direction) if direction == 'left' and scenario == 'return40': while ui.s.t < 39: commit(ui, 'wait') commit(ui, 'use') if scenario in ('next', 'undo_next') and STAGE >= 4: commit(ui, 'next') if scenario == 'undo_next': tap(ui, 'X') if scenario in ('saved','loaded') and STAGE == 5: tap(ui, 'S' if scenario == 'saved' else 'L') else: raise ValueError('scenario not available at this stage') def parse_args(argv=None): parser = argparse.ArgumentParser(description='Single-file Pyxel lesson 093') parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--demo-checkpoint', action='store_true') parser.add_argument('--scenario', choices=['initial','checkpoint','wall','confirm'] + (['caught','preview','timeout'] if STAGE >= 2 else []) + (['returned','return40'] if STAGE >= 3 else []) + (['next','undo_next','success'] if STAGE >= 4 else []) + (['saved','loaded'] if STAGE == 5 else [])) parser.add_argument('--screenshot', type=Path) parser.add_argument('--state-json', type=Path) parser.add_argument('--save-file', type=Path, default=Path('save_093.json')) args = parser.parse_args(argv) if not args.headless_smoke and (args.demo_checkpoint or args.scenario or args.screenshot or args.state_json): parser.error('diagnostic flags require --headless-smoke') if args.demo_checkpoint and args.scenario: parser.error('choose --demo-checkpoint or --scenario') # Resolve before pyxel.init, which can alter working-directory behavior. for name, suffix in (('screenshot','.png'),('state_json','.json')): path = getattr(args, name) if path is None: continue path = path.resolve() if path.suffix.lower() != suffix or not path.parent.is_dir() or path.exists(): parser.error(name + ': require a new file with correct suffix in an existing directory') setattr(args, name, path) args.save_file = args.save_file.resolve() if args.save_file.suffix.lower() != '.json' or not args.save_file.parent.is_dir() or args.save_file.is_dir(): parser.error('save-file: require a .json file in an existing directory') if args.save_file in (args.screenshot, args.state_json): parser.error('save-file and diagnostic outputs must differ') if args.scenario == 'saved' and args.save_file.exists(): parser.error('saved scenario must use a new save file') return args class App: def __init__(self, args): self.args = args self.ui = Controller(args.save_file) self.frame = 0 if args.headless_smoke: demo(self.ui, args.scenario or ('checkpoint' if args.demo_checkpoint else 'initial')) pyxel.init(560, 400, title='093 THREE NIGHTS / 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 update(self): self.frame += 1 if self.args.headless_smoke: if self.frame >= 3: pyxel.quit() return event = self.ui.feed({key for key, code in self.keys.items() if pyxel.btn(code)}) if event == 'quit': pyxel.quit() def draw(self): u, s = self.ui, self.ui.s pyxel.cls(1) pyxel.text(12, 10, '093 THREE NIGHTS | STAGE ' + str(STAGE), 10) pyxel.text(12, 24, 'SELECT: ' + u.selected.upper() + ' TICK: ' + str(s.t) + ' POS: ' + str(s.pos), 7) pyxel.text(300, 24, 'NIGHT: ' + str(getattr(s,'night',0)+1) + ' PHASE: ' + getattr(s,'phase','play').upper(), 11) for p in range(35): x, y = 12 + p % 7 * 42, 48 + p // 7 * 42 pyxel.rect(x, y, 39, 39, 5 if p in FLOOR else 2) if p in FLOOR: pyxel.rectb(x, y, 39, 39, 13) if STAGE >= 2 and p in GATES: g = s.guards[GATES.index(p)] pyxel.rectb(x+3,y+3,33,23,8 if occupied(g,s.t) else 11) pyxel.text(x+5,y+28,'G'+str(g),7) if p == START: pyxel.text(x+3,y+29,'S',11) if STAGE >= 3 and p == TREASURE: pyxel.text(x+3,y+29,'T',10) if not s.holding and s.phase == 'play': pyxel.circ(x+25,y+16,5,10) x,y = 12+s.pos%7*42+19,48+s.pos//7*42+14 pyxel.circ(x,y,6,12) if getattr(s,'holding',False): pyxel.circ(x+9,y,3,10) pyxel.rect(315,48,233,210,2) if STAGE >= 2: pyxel.text(325,60,'PUBLIC GUARDS / NEXT 3 TICKS' if getattr(s,'phase','play') == 'play' else 'RECORDED GUARDS / FINISHED NIGHT',10) for i,g in enumerate(s.guards): future = ' '.join('BUSY' if occupied(g,s.t+k) else 'OPEN' for k in (1,2,3)) pyxel.text(325,80+i*18,str(i)+': '+(future if getattr(s,'phase','play') == 'play' else 'LEVEL '+str(g)),7) pyxel.text(325,143,'FAILS: '+str(s.fails)+' TOTAL: '+str(s.total),7) if STAGE >= 3: pyxel.text(325,163,'HOLDING: '+str(int(s.holding)),10) pyxel.text(325,179,'HOME: '+str(len(s.items))+' USED BITS: '+str(s.used),11) phase = getattr(s,'phase','play') if phase == 'play': preview = step(s,u.selected) msg = 'NO CHANGE' if preview == s else 'CAUGHT / RESET' if getattr(preview,'fails',0)>getattr(s,'fails',0) else 'VALID ONE TICK' pyxel.text(325,208,'PREVIEW: '+msg,8 if 'RESET' in msg else 6) pyxel.text(325,225,'ENTER commits; preview is read-only.',7) else: pyxel.text(325,208,'RESULT: '+phase.upper(),10) if phase == 'intermission': next_guards = adapted(s.guards,s.used) if s.variant == 'adaptive' else s.guards pyxel.text(325,224,'NEXT GUARDS: '+str(next_guards),11) else: pyxel.text(325,224,'NO FUTURE GUARD UPDATE',11) pyxel.text(12,272,'Arrows: select direction ENTER: commit Release ALL keys between presses',7) if STAGE >= 2: pyxel.text(12,288,'SPACE: select wait R: confirm retry (current night only)',6) if STAGE >= 3: pyxel.text(12,304,'Z: select pickup/return X: Undo one committed decision',6) if STAGE >= 4: pyxel.text(12,320,'N: new M: menu H/P: help Title: 1/2/3 case, B variant',6) if STAGE == 5: pyxel.text(12,336,'S/L: save/load at intermission or result only',6) pyxel.text(12,358,'ESC: confirm quit. R never reloads edited Python code.',7) if u.notice: notice = 'SAVE OK' if '保存しました' in u.notice else 'LOAD OK' if '読み込みました' in u.notice else 'SAVE/LOAD ERROR: file preserved' if '読めません' in u.notice else 'SAVE/LOAD: intermission or result only' pyxel.text(12,375,notice,10) if u.title or u.help: pyxel.rect(35,70,490,175,1); pyxel.rectb(35,70,490,175,10) pyxel.text(50,85,'THREE NIGHTS: BRING HOME THREE FICTIONAL KEEPSAKES',10) pyxel.text(50,110,'Directions / SPACE / Z select. ENTER commits one action.',7) pyxel.text(50,130,'No real-world intrusion instructions. This is a grid puzzle.',6) pyxel.text(50,155,'Case: '+s.case+' Variant: '+s.variant,11) pyxel.text(50,175,'1/2/3 choose case. B changes adaptation before starting.',7) pyxel.text(50,205,'ENTER: start H/P: close help. Release keys first.',10) if u.dialog: pyxel.rect(35,125,490,105,1); pyxel.rectb(35,125,490,105,8) pyxel.text(50,145,'CONFIRM: '+u.dialog.upper(),10) pyxel.text(50,170,'Release all keys, then ENTER confirms. X cancels.',7) pyxel.text(50,193,'Retry keeps previous nights. New resets all three.',6) if self.args.headless_smoke and self.frame == 2: if self.args.screenshot: pyxel.screenshot(str(self.args.screenshot),scale=1) if self.args.state_json: data={'stage':STAGE,'state':asdict(s),'selected':u.selected,'title':u.title, 'dialog':u.dialog,'history':[asdict(v) for v in u.history], 'scope':'Actual Pyxel Linux headless rendering; synthetic controller inputs'} with self.args.state_json.open('x',encoding='utf-8') as handle: json.dump(data,handle,ensure_ascii=False,indent=2) if __name__ == '__main__': args = parse_args() import pyxel App(args)