# 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 紙片のラリー / 第2段階。LESSON.md を参照。""" STAGE=2 STAGE_NOTE='HOLD UP 4 TICKS: SPEED +1 / HOLD DOWN 2 TICKS: SPEED -1' """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('LESSON-STRAIGHT','直線練習',(('START',((120,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;seg=s.segment;off=s.offset;touch=False for _ in range(v): part=road[seg] off+=1 if off==part.length: seg+=1;off=0 if seg==len(road):break 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') class App: def __init__(self): self.level=0 self.fold='' self.wide=False self.mode='prep' pass 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 pass def tick(self,action): self.s=step(self.s,self.road,action) pass if self.s.outcome=='won': self.mode='result' pass 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': pass 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((0,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'} pass 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') pass args=parser.parse_args() pass 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)