# 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. """057 紙片のラリー / 第5段階。LESSON.md を参照。""" STAGE=5 STAGE_NOTE='SAME-COURSE GHOST / REPLAY HASHES VERIFY EVERY LOGICAL TICK' """057 deterministic integer rally. No renderer, clock, filesystem or random state.""" from dataclasses import dataclass, asdict, replace import hashlib,json RULE_VERSION='paper-rally-r1' GENERATOR_VERSION='module-bank-g1' ENV_VERSION='integer-10Hz-v1' TICK_RATE=10 @dataclass(frozen=True) class Segment: length:int curve:int width:int module:str @dataclass(frozen=True) class Level: seed:str title:str modules:tuple # Both alternatives connect the same ordered entry/exit sockets; no arbitrary geometry. LEVELS=( Level('A-01-g1','広い迂回',(('出発',((80,0,14),),None),('A',((80,0,14),(40,1,14),(40,-1,14),(80,0,14)),((35,0,8),(30,2,8),(30,-2,8),(35,0,8))),('帰着',((80,0,14),),None))), Level('B-01-g1','出口で速度を戻す',(('出発',((60,0,14),),None),('A',((50,1,13),(50,-1,13),(80,0,13)),((35,0,8),(25,2,8),(25,-2,8),(25,0,8))),('帰着',((140,0,14),),None))), Level('C-01-g1','どちらを折る',(('出発',((50,0,14),),None),('A',((60,1,14),(60,-1,14)),((30,2,8),(30,-2,8))),('中間',((50,0,14),),None),('B',((80,0,14),(40,1,14),(40,-1,14)),((35,2,12),(35,-2,12),(40,0,12))),('帰着',((50,0,14),),None))) ) @dataclass(frozen=True) class State: tick:int=0 segment:int=0 offset:int=0 q:int=0 v:int=0 accel:int=0 brake:int=0 contacts:int=0 stopped:int=0 outcome:str='running' def folds(level):return ('',)+tuple(name for name,_,folded in level.modules if folded is not None) def course(level,fold='',wide=False): if fold not in folds(level):raise ValueError('Unknown fold in finite bank') if wide and (level.seed[0]!='A' or fold!='A'):raise ValueError('Width comparison is only A folded') return tuple(Segment(d,c,14 if wide and name==fold else w,name) for name,opened,folded in level.modules for d,c,w in (folded if name==fold else opened)) def initial():return State() def distance(s,road):return sum(seg.length for seg in road[:s.segment])+s.offset def total(road):return sum(s.length for s in road) def at(road,d): d=max(0,min(d,total(road)-1)) for i,seg in enumerate(road): if d=4:v=min(4,v+1);ac=0 if br>=2:v=max(0,v-1);br=0 q=s.q+16*steer;seg=s.segment;off=s.offset;touch=False # Check the current shoulder before movement; a zero-speed steer cannot escape. bound=(road[seg].width-2)*16 if abs(q)>bound:q=max(-bound,min(bound,q));touch=True for _ in range(v): part=road[seg];q+=part.curve*v;bound=(part.width-2)*16 if abs(q)>bound:q=max(-bound,min(bound,q));touch=True off+=1 if off==part.length: seg+=1;off=0 if seg==len(road):break bound=(road[seg].width-2)*16 if abs(q)>bound:q=max(-bound,min(bound,q));touch=True return State(s.tick+1,seg,off,q,1 if touch else v,ac,br,s.contacts+int(touch),s.stopped+int(v==0),'won' if seg==len(road) else 'running') def canonical(obj):return json.dumps(obj,sort_keys=True,separators=(',',':'),ensure_ascii=False) def digest(obj):return hashlib.sha256(canonical(obj).encode('utf-8')).hexdigest() def state_hash(s):return digest(asdict(s)) def board_data(level,fold='',wide=False): return {'level':asdict(level),'fold':fold,'wide':wide,'segments':[asdict(s) for s in course(level,fold,wide)],'rules':RULE_VERSION,'generator':GENERATOR_VERSION,'environment':ENV_VERSION} def course_hash(level,fold='',wide=False):return digest(board_data(level,fold,wide)) def replay_data(level,inputs,fold='',wide=False): road=course(level,fold,wide);s=initial();events=[] for a in inputs: before=s;s=step(s,road,a);events.append({'logical_tick':before.tick,'action':list(a),'state_sha256':state_hash(s)}) b=board_data(level,fold,wide) return {'board':b,'board_sha256':digest(b),'inputs':events,'final_state':asdict(s),'final_sha256':state_hash(s)} def check_replay(data): b=data['board'];matches=[l for l in LEVELS if l.seed==b['level']['seed']] if len(matches)!=1:raise ValueError('Unknown finite seed') level=matches[0];expected=board_data(level,b['fold'],b['wide']) if canonical(expected)!=canonical(b):raise ValueError('Board or rule/generator/environment mismatch') if digest(b)!=data['board_sha256']:raise ValueError('Board hash mismatch') actual=replay_data(level,[e['action'] for e in data['inputs']],b['fold'],b['wide']) if canonical(actual)!=canonical(data):raise ValueError('Input ticks/intermediate/final hash mismatch') return actual['final_sha256'] class App: def __init__(self): self.level=0 self.fold='' self.wide=False self.mode='prep' self.best={} self.reset() @property def board(self):return LEVELS[self.level] @property def road(self):return course(self.board,self.fold,self.wide) def reset(self): self.s=initial() self.phase=0 self.pause=False self.gate=False self.inputs=[] self.trace=[self.s] def tick(self,action): self.s=step(self.s,self.road,action) self.inputs.append(action) self.trace.append(self.s) if self.s.outcome=='won': self.mode='result' key=course_hash(self.board,self.fold,self.wide) old=self.best.get(key) if old is None or (self.s.tick,self.s.contacts)<(old[-1].tick,old[-1].contacts): self.best[key]=tuple(self.trace) def control(self,pressed,held): if self.gate: if not held:self.gate=False return if 'R' in pressed: self.reset();self.mode='race';self.gate=True;return if 'P' in pressed and self.mode=='race': self.pause=not self.pause;self.gate=True;return if 'X' in pressed: self.reset();self.mode='prep';self.gate=True;return if self.pause:return if self.mode=='prep': for i,key in enumerate(('1','2','3')): if key in pressed: self.level=i;self.fold='';self.wide=False;self.reset();self.gate=True;return if 'SPACE' in pressed: choices=folds(self.board);self.fold=choices[(choices.index(self.fold)+1)%len(choices)] self.wide=False;self.reset();self.gate=True;return if 'B' in pressed and self.level==0 and self.fold=='A': self.wide=not self.wide;self.reset();self.gate=True;return if 'RETURN' in pressed or 'Z' in pressed: self.reset();self.mode='race';self.gate=True;return return if self.mode!='race':return self.phase+=1 if self.phase==3: self.phase=0 self.tick((int('RIGHT' in held)-int('LEFT' in held),int('UP' in held)-int('DOWN' in held))) def draw(self,p): s=self.s;road=self.road;d=distance(s,road) p.cls(1) p.text(12,10,'057 PAPER RALLY / LESSON '+str(STAGE),10) p.text(12,28,self.board.seed+' / FOLD '+(self.fold or 'OPEN')+' / WIDTH '+('14 COMPARE' if self.wide else 'NORMAL'),6) p.text(12,47,'MODE '+self.mode.upper()+(' / PAUSED' if self.pause else '')+' / TICK '+str(s.tick)+' / TIME '+format(s.tick/TICK_RATE,'.1f')+'s',7) p.text(430,47,'DIST '+str(d)+' / '+str(total(road)),11) if self.mode=='prep': p.rect(12,76,676,210,7) p.text(24,88,'FINITE MODULE BANK / SYMBOLIC SOCKET DIAGRAM',1) dx=640//len(self.board.modules) for i,(name,opened,folded) in enumerate(self.board.modules): x=28+i*dx;end=x+dx-18;mid=(x+end)//2 if folded: paths=(((x,180),(x+dx//4,130),(end-dx//4,130),(end,180)),((x,180),(mid,162),(end,180))) for j,path in enumerate(paths): color=8 if (j==1)==(self.fold==name) else 6 for a,b in zip(path,path[1:]):p.line(*a,*b,color) p.line(mid,112,mid,216,9) else:p.line(x,180,end,180,1) p.circ(x,180,3,12);p.circ(end,180,3,12) if i+1=limit: if screenshot:app.draw(p);p.screen.save(screenshot,1) if report: from pathlib import Path data={'stage':STAGE,'mode':app.mode,'state':asdict(app.s),'scope':'actual Pyxel; synthetic held input; not OS keyboard or human playtest'} data['replay']=replay_data(app.board,app.inputs,app.fold,app.wide) Path(report).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8') p.quit();return if headless: held=demo_held(p.frame_count) if demo else set();pressed=held-previous;previous=held else: held={k for k,v in keys.items() if p.btn(v)};pressed={k for k,v in keys.items() if p.btnp(v)} app.control(pressed,held) p.run(update,lambda:app.draw(p)) if __name__=='__main__': import argparse from pathlib import Path parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke',action='store_true');parser.add_argument('--demo-checkpoint',action='store_true') parser.add_argument('--screenshot');parser.add_argument('--report') parser.add_argument('--check-replay',type=Path) args=parser.parse_args() if args.check_replay: if args.headless_smoke or args.demo_checkpoint or args.screenshot or args.report:parser.error('check-replay is a separate read-only mode') try: data=json.loads(args.check_replay.read_text(encoding='utf-8')) if not isinstance(data,dict):raise ValueError('replay must be a JSON object') print(check_replay(data.get('replay',data))) except (OSError,ValueError,KeyError,TypeError,IndexError) as error:parser.error(str(error)) raise SystemExit(0) if (args.demo_checkpoint or args.screenshot or args.report) and not args.headless_smoke:parser.error('demo and output require --headless-smoke') for field,suffix in (('screenshot','.png'),('report','.json')): value=getattr(args,field) if value: target=Path(value).resolve() if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('use a new '+suffix+' in an existing directory') setattr(args,field,str(target)) main(args.headless_smoke,args.screenshot,args.report,args.demo_checkpoint)