# 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. """100 旅する修理屋 / 教材 3。""" STAGE = 3 from dataclasses import dataclass, asdict, replace from pathlib import Path import json, hashlib GAME='100_traveling_repairer' VERSION=1 TITLES={'A':'川から始める','B':'森への便り','C':'鈴を届けたあと'} TOWNS=('拠点','川町','丘町','森町') GIFTS=('小瓶','小鈴','木札') INITIAL={'A':(20,4,(2,3,3,2)),'B':(18,3,(3,2,3,2)),'C':(16,2,(2,2,2,3))} METHODS={'gear':(2,1),'hand':(1,2),'rope':(1,2)} @dataclass(frozen=True) class State: case: str = 'A' transport: bool = True town: int = 0 time: int = 20 materials: int = 4 fixed: tuple = ('','') visited: tuple = (True,False,False,False) offset: int = 2 ended: bool = False def new(case='A',transport=True): if case not in INITIAL or type(transport) is not bool:raise ValueError('invalid start') return State(case=case,transport=transport,time=INITIAL[case][0],materials=INITIAL[case][1]) def trial(s,mechanism,method): if mechanism!=0:raise ValueError('only the ferry is taught here') return {'passes':any(row[2] for row in ferry_trial(s.offset)),'frames':ferry_trial(s.offset)} def roads(s): costs=INITIAL[s.case][2];r={(0,1):costs[0],(0,2):costs[1],(1,3):costs[2],(2,3):costs[3]} if s.transport: if s.fixed[0]:r[(1,2)]=1 if s.fixed[1]:r[(0,3)]=1 return r def distance(s,a,b):return roads(s).get(tuple(sorted((a,b)))) def shortest(s,start=None): start=s.town if start is None else start;ds=[999]*4;ds[0]=0;paths=[() for _ in range(4)];paths[0]=(0,) for _ in range(4): for (a,b),cost in roads(s).items(): for u,v in ((a,b),(b,a)): path=(v,)+paths[u] if ds[u]+cost=2 and self.s.town==1 and not getattr(self.s,'fixed',('',''))[0]:out.append(('TOOLS',('tools',))) if self.s.town==0:out.append(('FINISH',('finish',))) return out def apply(self,a): try: self.s=step(self.s,a);self.notice='';self.tested=None if a[0]=='move':self.view='town';self.destination=0 if self.s.town else 1;self.method='gear' if a[0]=='repair':self.view='map' return 'step:'+a[0] except ValueError as e:self.notice=str(e);return 'invalid' def preview(self,a): try:step(self.s,a);self.pending=a;self.dialog='action';return 'confirm:action' except ValueError as e:self.notice=str(e);return 'invalid:choice' def feed(self,down): down=set(down)&KEYS;fresh=down-self.previous;self.previous=down if self.testing and not (self.paused or self.help or self.dialog or self.title): self.test_frame+=1 if self.test_frame>=120: self.testing=False;self.tested=self.signature();self.gate=True;return 'test:complete' if self.gate: if not down:self.gate=False return None if not fresh:return None self.gate=True if len(down)>1:return 'ignored:chord' k=next(iter(fresh));self.notice='' if self.dialog: if k in ('X','ESCAPE'):self.dialog='';self.pending=None;return 'cancel' if k=='RETURN': d=self.dialog;self.dialog='' if d=='quit':return 'quit' if d=='retry':self.reset();return 'reset' if d=='menu':self.title=True;return 'menu' if d=='action':a=self.pending;self.pending=None;return self.apply(a) return 'ignored:dialog' if k=='ESCAPE':self.dialog='quit';return 'confirm:quit' if k=='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 STAGE>=3 and k=='B':self.s=new(self.s.case,not self.s.transport);self.normalize();return 'variant' if k in ('SPACE','RETURN'):self.title=False;return 'start' return 'ignored:title' if k=='P':self.paused=not self.paused;return 'pause' if self.paused:return 'ignored:pause' if self.testing:return 'ignored:test' if k=='R':self.dialog='retry';return 'confirm:retry' if k=='M':self.dialog='menu';return 'confirm:menu' if self.s.ended:return 'ignored:result' available=STAGE>=2 and self.s.town==1 and not getattr(self.s,'fixed',('',''))[0] if k=='TAB': views=('map','town','tools') if available else ('map','town') self.view=views[(views.index(self.view)+1)%len(views)] if self.view in views else 'map';return 'view' if k=='Z' and available:self.view='tools';return 'tools' if self.view=='map': if k in ('LEFT','RIGHT'):self.destination=(self.destination+(-1 if k=='LEFT' else 1))%4;return 'select-destination' if k=='RETURN':return self.preview(('move',self.destination)) elif self.view=='town': if k=='RETURN' and self.options(): a=self.options()[0][1] if a==('tools',):self.view='tools';return 'tools' return self.preview(a) else: if STAGE>=3 and k in ('LEFT','RIGHT'):self.method='hand' if self.method=='gear' else 'gear';self.tested=None;return 'select-method' if k in ('UP','DOWN'):return self.apply(('offset',(self.s.offset+(1 if k=='UP' else -1))%4)) if k=='RETURN': if STAGE>=3 and self.tested==self.signature() and any(t[2] for t in ferry_trial(self.s.offset)):return self.preview(('repair',0,self.method)) self.testing=True;self.test_frame=0;self.tested=None;return 'test:start' return 'ignored:key' # ASCII teaching view. Controllers alone decide whether an input is committed. NAMES=('H HOME','R RIVER','K HILL','F FOREST') GIFT_NAMES=('JAR','BELL','TAG') POINTS=((65,208),(223,110),(223,305),(393,208)) def tap(ui,key): ui.feed(set());event=ui.feed({key});ui.feed(set());return event def go(ui,destination): while ui.view!='map':tap(ui,'TAB') for _ in range((destination-ui.destination)%4):tap(ui,'RIGHT') first=tap(ui,'RETURN') if first!='confirm:action':raise ValueError(first+': '+ui.notice) return tap(ui,'RETURN') def run_trial(ui): tap(ui,'RETURN') for _ in range(120):ui.feed(set()) ui.feed(set()) if ui.testing:raise AssertionError('trial did not finish') class App: def __init__(self,save_path='save_100.json'): self.ui=Controller(save_path);self.event='READY' def draw(self,p): u=self.ui;s=u.s;p.cls(1) p.text(16,12,'TRAVELING REPAIRER / STAGE '+str(STAGE)+' / CASE '+s.case,10) p.text(16,30,'CURRENT '+NAMES[s.town]+' TIME '+str(s.time)+' MATERIALS '+str(getattr(s,'materials','-')),11) p.text(440,30,'INITIAL ROADS / NO REPAIRS YET' if STAGE<3 else 'TRAVEL EDGES '+('OPEN AFTER REPAIR' if s.transport else 'COMPARISON: INITIAL ROADS ONLY'),6) p.text(16,49,'VIEW '+u.view.upper()+(' / CHOOSING DOES NOT USE TRAVEL TIME' if STAGE==1 else ' / CHOOSING AND FREE TESTS DO NOT USE TRAVEL TIME'),7) if u.view=='tools' and not s.ended: p.rectb(16,70,430,305,5) mech=u.mechanism();p.text(30,86,'FERRY' if mech==0 else 'FOREST ROPE' if u.method=='rope' else 'FOREST GEARS',10) if mech==0: frames=ferry_trial(s.offset);phase=min(3,u.test_frame//30) if u.testing or u.tested else 0 p.text(30,108,'OFFSET '+str(s.offset)+' / UP DOWN: 0..3',7) for i,(left,right,aligned) in enumerate(frames): y=142+i*36 if i==phase:p.rect(25,y-5,406,28,5) p.text(34,y,'PHASE '+str(i)+' LEFT '+str(left)+' RIGHT '+str(right)+' '+('DEPART' if aligned else 'WAIT'),10 if aligned else 7) p.text(30,305,'THE FOUR ROWS ARE A FREE MECHANISM TEST',6) p.text(30,322,'THEY ARE NOT FOUR TRAVEL-TIME CHARGES',6) elif u.method=='rope': p.line(75,298,380,135,6);p.line(75,304,380,141,6) p.rect(195,280-s.stop*57,44,20,9);p.rect(245,225,130,6,4) p.text(30,108,'STOP '+str(s.stop)+' / UP DOWN: 0..2',7) p.text(256,243,'PIER = STOP 1',10) p.text(30,338,'PASS ONLY WHEN THE LOAD STOPS AT 1',6) else: result=gear_trial(s.edges);coords=((95,218),(230,144),(365,218)) for i,(a,b) in enumerate(((0,1),(1,2),(0,2))): x,y=coords[a];xx,yy=coords[b];p.line(x,y,xx,yy,10 if s.edges&(1<'.join('HRKF'[n] for n in path),11) if s.time=3:p.text(x,136,'FIXED '+str(s.fixed),7) if STAGE>=4: carried=[i for i,v in enumerate(s.gifts) if v==-1] p.text(x,158,'BAG '+('/'.join(GIFT_NAMES[i] for i in carried) or 'EMPTY')+' / 2 SLOTS',7) p.text(x,180,'GIFT LOCATIONS: -1 = BAG',6) for i,g in enumerate(s.gifts):p.text(x,198+i*16,GIFT_NAMES[i]+': '+('BAG' if g==-1 else NAMES[g]),7) p.text(x,250,'SUPPLY TAKEN '+str(s.supplied),7) p.text(x,270,'TOWN LIFE: '+(','.join(('FISH','PLANTS','BELL')[i] for i in s.life) or 'NONE'),11) if STAGE==5:p.text(x,297,'HISTORY '+str(len(s.history))+' DECISIONS',6) if s.ended: result='SUCCESS' if STAGE>=3 and success(s) else 'SCOUTING / NO REPAIR' if STAGE>=3 else 'TRAINING TOUR ENDED' p.text(16,397,'RESULT: '+result,10) elif u.view=='map': cost=distance(s,s.town,u.destination) p.text(16,397,'DESTINATION '+NAMES[u.destination]+' / COST '+str(cost)+' / LEFT RIGHT THEN ENTER',10) elif u.view=='town': options=u.options();a=options[u.choice%len(options)][1] if options else () p.text(16,397,'TOWN ACTION '+str(a)+' / LEFT RIGHT THEN ENTER' if options else 'NO TOWN ACTION / TAB TO MAP',10) if STAGE>=4 and len([g for g in s.gifts if g==-1])==2: bag=[i for i,g in enumerate(s.gifts) if g==-1] p.text(16,416,'SWAP: LEAVE '+GIFT_NAMES[bag[u.old%2]]+' HERE / UP DOWN SELECT',7) else: passed=any(row[2] for row in ferry_trial(s.offset)) if STAGE<4 else trial(s,u.mechanism(),u.method)['passes'] status='TEST '+str(u.test_frame)+'/120 FEEDS' if u.testing else ('TEST DONE: '+('PASS' if passed else 'FAIL') if u.tested==u.signature() else 'ENTER STARTS FREE TEST') p.text(16,397,status+' / TRAVEL TIME UNCHANGED',10) if STAGE>=3: mat,time=METHODS[u.method];p.text(16,416,'METHOD '+u.method.upper()+' / MATERIALS '+str(mat)+' / TIME '+str(time)+' / LEFT RIGHT CHANGE',7) p.text(16,435,'AFTER PASS: RELEASE, ENTER PREVIEW, RELEASE, ENTER COMMIT',6) else:p.text(16,416,'STAGE 02: TEST ONLY / NO REPAIR COMMIT YET',6) if u.notice: reason='SAVED' if self.event=='save' else 'LOADED' if self.event=='load' else 'ACTION/IO REJECTED' for token,label in (('帰り','RETURN RESERVE TOO LOW'),('資材','NOT ENOUGH MATERIALS'),('試運転','ADJUST AND RETEST'),('その二町','NO DIRECT ROAD'),('時間','NOT ENOUGH TRAVEL TIME'),('丘の工具','SUPPLY ALREADY TAKEN'),('保存を変更','SAVE/LOAD FAILED; STATE KEPT')): if token in u.notice:reason=label p.text(470,333,'NOTICE: '+reason,8) p.text(16,462,'TAB MAP/TOWN / ENTER PREVIEW THEN CONFIRM' if STAGE==1 else 'TAB MAP/TOWN/TOOLS / Z TOOLS / ENTER PREVIEW THEN CONFIRM',7) p.text(16,480,'H HELP / P PAUSE / R RETRY / M TITLE / ESC QUIT / X CANCEL DIALOG',6) p.text(16,498,'X UNDO / S SAVE / L LOAD (STAGE 05) / RELEASE EVERY KEY',6) p.text(16,516,'R RESETS STATE ONLY. SAVE CODE, QUIT, AND RERUN AFTER EDITS.',6) p.text(16,540,'LAST INPUT '+self.event[:100],10) if u.title or u.help: p.rect(32,66,716,379,1);p.rectb(32,66,716,379,10) lines=['TRAVELING REPAIRER / STAGE '+str(STAGE),'TITLE: 1 2 3 CASE / B COMPARISON (03+) / SPACE START', 'MAP: LEFT RIGHT DESTINATION / ENTER PREVIEW / ENTER COMMIT', 'TOWN: LEFT RIGHT ACTION / ENTER PREVIEW / ENTER COMMIT', 'TAB MAP/TOWN / NO TOOLS IN STAGE 01' if STAGE==1 else 'TAB CHANGES VIEW / Z OPENS AN UNREPAIRED MECHANISM', 'TOOLS: UP DOWN PHASE OR STOP / LEFT RIGHT METHOD (03+)', 'FOREST GEARS: 1 2 3 TOGGLE EDGES (04+)', 'FREE MECHANISM TESTS ARRIVE IN STAGE 02' if STAGE==1 else 'ENTER FREE TEST: 120 UNPAUSED FEEDS, 4 DISPLAY PHASES', 'REPAIR COMMIT ARRIVES IN STAGE 03' if STAGE<3 else 'AFTER PASS: RELEASE / ENTER PREVIEW / RELEASE / ENTER COMMIT', 'H CLOSES HELP / P PAUSES / R M ESC REQUIRE CONFIRMATION', 'X CANCELS A DIALOG / X UNDO, S SAVE, L LOAD (05)', 'EDITS AND TESTS ARE FREE; MOVES AND REPAIRS USE TIME', 'CURRENT CASE '+s.case+' / '+('OPEN TRAVEL EDGES' if getattr(s,'transport',True) else 'LIFE ONLY COMPARISON')] for i,line in enumerate(lines):p.text(50,86+i*27,line,10 if i in (0,12) else 7) if u.paused: p.rect(60,165,660,135,1);p.rectb(60,165,660,135,10) p.text(83,191,'PAUSED / P RESUMES / RELEASE ALL KEYS FIRST',10) p.text(83,229,'TRIAL CLOCK AND TRAVEL TIME ARE STOPPED',7) if u.dialog: p.rect(48,88,684,339,1);p.rectb(48,88,684,339,10) lines=['CONFIRM '+u.dialog.upper(),'ENTER ACCEPT / X CANCEL / RELEASE BETWEEN EVERY PRESS'] if u.dialog=='action': future=step(s,u.pending);return_cost,route=shortest(future) lines=['PENDING ACTION '+str(u.pending),'CURRENT: TIME '+str(s.time)+' MATERIALS '+str(getattr(s,'materials','-')), 'AFTER CONFIRM: TIME '+str(future.time)+' MATERIALS '+str(getattr(future,'materials','-')), 'AFTER CONFIRM HOME: '+str(return_cost)+' VIA '+'>'.join('HRKF'[n] for n in route)] if STAGE>=4 and u.pending[0]=='gift':lines.append('AFTER CONFIRM GIFT LOCATIONS '+str(future.gifts)+' (-1 = BAG)') if u.pending[0]=='move' and future.time=2: tap(u,'Z');tap(u,'DOWN');tap(u,'DOWN');run_trial(u) if STAGE>=3: tap(u,'RETURN');tap(u,'RETURN') if STAGE>=4: go(u,2) # Hill options are gift first, supply second. tap(u,'RIGHT');tap(u,'RETURN');tap(u,'RETURN') app.event='SYNTHETIC CHECKPOINT' def main(headless=False,screenshot=None,state_json=None,checkpoint=False): save_path=str((Path.cwd()/'save_100.json').resolve()) import pyxel as p app=App(save_path) if checkpoint:demo(app) p.init(780,570,title='Traveling repairer lesson '+str(STAGE),fps=30,display_scale=1,quit_key=p.KEY_NONE,headless=headless) keys={k:getattr(p,'KEY_'+k) for k in KEYS} def update(): if headless and p.frame_count>=5: app.draw(p) if screenshot:p.screen.save(screenshot,1) if state_json:Path(state_json).write_text(json.dumps({'stage':STAGE,'state':asdict(app.ui.s),'title':app.ui.title,'scope':'actual Linux headless Pyxel; synthetic controls'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8') p.quit();return e=app.ui.feed(set() if headless else {k for k,v in keys.items() if p.btn(v)}) if e: app.event=e if e=='quit':p.quit() p.run(update,lambda:app.draw(p)) if __name__=='__main__': import argparse parser=argparse.ArgumentParser(description='Standalone traveling repairer tutorial') parser.add_argument('--headless-smoke',action='store_true') parser.add_argument('--demo-checkpoint',action='store_true') parser.add_argument('--screenshot');parser.add_argument('--state-json') args=parser.parse_args() if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:parser.error('outputs/checkpoint require --headless-smoke') for field,suffix in (('screenshot','.png'),('state_json','.json')): value=getattr(args,field) if value is not None: target=Path(value).resolve() if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('output must be a new '+suffix+' in an existing directory') setattr(args,field,str(target)) main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)