# -*- 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 = 5 """Three-night infiltration. Pure, immutable and deterministic; no private AI input.""" from dataclasses import dataclass,replace,asdict,fields import hashlib,json VERSION='093-1.0';GAME_ID='093';START=14;TREASURE=20;GATES=(3,17,31) TITLES={'A':'静かな一夜目','B':'窓番のいる館','C':'足あとが多い館'} INITIAL={'A':(0,0,0),'B':(1,0,1),'C':(2,1,0)} ITEMS=('月のティーカップ','星のオルゴール','朝の小さな鏡') ROUTES=('上の窓','中央の扉','下の窓');ACTIONS=('up','down','left','right','wait','use') FLOOR=frozenset(p for p in range(35) if p%7 in (0,6) or p//7 in (0,2,4)) @dataclass(frozen=True) class State: case:str='A';variant:str='adaptive';night:int=0;pos:int=14;holding:bool=False;t:int=0;guards:tuple=(0,0,0);used:int=0;items:tuple=();phase:str='play';fails:int=0;total:int=0;previous_used:int=0;trace:tuple=(14,);note:str='今夜はどの道から?' def new(case='A',variant='adaptive'): if case not in INITIAL or variant not in ('adaptive','fixed'):raise ValueError('invalid setup') return State(case=case,variant=variant,guards=INITIAL[case]) def occupied(level,t):return level==1 and t%2==0 or level==2 and t%3 in (0,1) def forecast(s):return tuple(tuple(occupied(g,s.t+k) for g in s.guards) for k in range(1,4)) def adapted(guards,used):return tuple(min(2,g+1) if used>>i&1 else max(0,g-1) for i,g in enumerate(guards)) def retry(s,note='この夜だけ最初から。前夜までの品はそのまま。',failure=False,spent=0): if s.phase!='play':return s return replace(s,pos=START,holding=False,t=0,used=0,trace=(START,),fails=s.fails+int(failure),total=s.total+spent,note=note) def step(s,a): if a=='next' and s.phase=='intermission': return replace(s,night=s.night+1,pos=START,holding=False,t=0,guards=adapted(s.guards,s.used) if s.variant=='adaptive' else s.guards,previous_used=s.used,used=0,phase='play',trace=(START,),note='昨日の足あとだけを覚えています。' if s.variant=='adaptive' else '比較版: 警備は初期値のまま。') if a=='retry':return retry(s) if s.phase!='play' or a not in ACTIONS:return s if a=='use' and not (s.pos==TREASURE and not s.holding or s.pos==START and s.holding):return s p=s.pos;d={'up':(0,-1),'down':(0,1),'left':(-1,0),'right':(1,0)}.get(a) if d: x,y=p%7+d[0],p//7+d[1];q=y*7+x if not(0<=x<7 and 0<=y<5 and q in FLOOR):return s p=q t=s.t+1 if p in GATES and occupied(s.guards[GATES.index(p)],t): return retry(s,'見つかった! この夜の入口から、もう一度。',True,t) used=s.used|(1<=40 and phase=='play':return retry(s,'40刻。今夜の入口から再試行できます。',True,t) return replace(s,pos=p,t=t,holding=holding,used=used,items=items,phase=phase,total=total,trace=s.trace+(p,),note=note) def digest(s):return hashlib.sha256(json.dumps(asdict(s),ensure_ascii=False,sort_keys=True).encode()).hexdigest() def from_dict(d): if not isinstance(d,dict) or set(d)!={f.name for f in fields(State)}:raise ValueError('state fields') d=dict(d) for k in ('guards','items','trace'): if not isinstance(d[k],list):raise ValueError('tuple field') d[k]=tuple(d[k]) s=State(**d) if s.case not in INITIAL or s.variant not in ('adaptive','fixed') or s.phase not in ('play','intermission','done'):raise ValueError('enum') for k,lo,hi in (('night',0,2),('pos',0,34),('t',0,40),('used',0,7),('previous_used',0,7),('fails',0,100000),('total',0,10000000)): if type(getattr(s,k)) is not int or not lo<=getattr(s,k)<=hi:raise ValueError(k) if type(s.holding) is not bool or not isinstance(s.note,str) or len(s.note)>200 or s.pos not in FLOOR:raise ValueError('state value') if len(s.guards)!=3 or any(type(x) is not int or x not in range(3) for x in s.guards):raise ValueError('guards') if s.items!=tuple(range(s.night+(s.phase!='play'))):raise ValueError('items') if s.phase=='intermission' and s.night==2 or s.phase=='done' and s.night!=2:raise ValueError('phase') if not 1<=len(s.trace)<=41 or any(type(x) is not int or x not in FLOOR for x in s.trace):raise ValueError('trace') if s.trace[-1]!=s.pos or len(s.trace)!=s.t+1:raise ValueError('trace clock') return s """UTF-8 atomic local save. Validate fully before replacing the live controller.""" import json,os from pathlib import Path from dataclasses import asdict def save(path,state,history,commands): p=Path(path);data={'game_id':GAME_ID,'version':VERSION,'case':state.case,'variant':state.variant,'state':asdict(state),'history':[asdict(s) for s in history],'semantic_inputs':commands,'settings':{'silent':True}} temp=p.with_suffix(p.suffix+'.tmp') with temp.open('w',encoding='utf-8',newline='\n') as f:json.dump(data,f,ensure_ascii=False,indent=2);f.flush();os.fsync(f.fileno()) os.replace(temp,p) def load(path): p=Path(path) if p.stat().st_size>8000000:raise ValueError('保存が大きすぎます') d=json.loads(p.read_text(encoding='utf-8')) if not isinstance(d,dict) or d.get('game_id')!=GAME_ID or d.get('version')!=VERSION:raise ValueError('未対応の作品または版です') s=from_dict(d['state']);h=d['history'];c=d['semantic_inputs'] if d['case']!=s.case or d['variant']!=s.variant or not isinstance(h,list) or len(h)>5000 or not isinstance(c,list) or len(c)>10000 or any(not isinstance(x,str) or len(x)>40 for x in c):raise ValueError('記録の形式が違います') history=[from_dict(x) for x in h] if any(x.case!=s.case or x.variant!=s.variant for x in history):raise ValueError('履歴の状況が違います') return s,history,c class storage: save = staticmethod(save) load = staticmethod(load) KEYS={'SPACE','RETURN','Z','X','P','H','R','N','M','UP','DOWN','LEFT','RIGHT','1','2','3','B','S','L','F12','ESCAPE'} class Controller: def __init__(self,path='save_093.json'): self.s=new();self.title=True;self.help=False;self.dialog='';self.previous=set();self.gate=False;self.history=[];self.log=[];self.notice='';self.path=path;self.selected='right' def reset(self,case=None):self.s=new(case or self.s.case,self.s.variant);self.history=[];self.log=[];self.notice='';self.selected='right' def apply(self,a): if a=='undo': if not self.history:return 'ignored:undo' self.s=self.history.pop();self.log.append(a);return a old=self.s;self.s=step(old,a) if self.s==old:return 'ignored:'+a self.history.append(old);self.log.append(a);return a 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' k=next(iter(fresh));self.notice='' if k=='F12':return 'screenshot' if self.dialog: if k in ('X','ESCAPE'):self.dialog='';return 'cancel' if k=='RETURN': d=self.dialog;self.dialog='' if d=='quit':return 'quit' if d=='new':self.reset();return 'reset:campaign' if d=='retry':return self.apply('retry') if d=='menu':self.title=True;return 'menu' return 'ignored:dialog' if k=='ESCAPE':self.dialog='quit';return 'confirm:quit' if k in ('P','H'):self.help=not self.help;return 'help' if self.help:return 'ignored:help' if self.title: if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);return 'case' if k=='B':self.s=new(self.s.case,'fixed' if self.s.variant=='adaptive' else 'adaptive');self.history=[];self.log=[];return 'variant' if k in ('SPACE','RETURN'):self.title=False;return 'start' return 'ignored:title' if k=='M':self.dialog='menu';return 'confirm:menu' if k=='N':self.dialog='new';return 'confirm:new' if k=='R' and self.s.phase=='play':self.dialog='retry';return 'confirm:retry' if k=='X':return self.apply('undo') if k in ('S','L'): if self.s.phase not in ('intermission','done'):self.notice='保存と読込は夜と夜の間、または三夜の結果で。';return 'blocked:save-phase' try: if k=='S':storage.save(self.path,self.s,self.history,self.log);self.notice='この三夜を保存しました。';return 'save' self.s,self.history,self.log=storage.load(self.path);self.notice='保存した三夜を読み込みました。';return 'load' except (ValueError,KeyError,TypeError,OSError):self.notice='保存を読めません。元のファイルは残しています。Nで新規開始。';return 'save-error' if self.s.phase=='intermission' and k=='RETURN':return self.apply('next') if self.s.phase!='play':return 'ignored:result' a={'UP':'up','DOWN':'down','LEFT':'left','RIGHT':'right','SPACE':'wait','Z':'use'}.get(k) if a:self.selected=a;return 'select:'+a if k=='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)