探索・アドベンチャーLESSON 99有限の旅刻と修理・記念品の旅パズル原作100

旅する修理屋の作り方

直した道で、帰れる時間があるかを確かめる。
旅の時間(旅刻)と材料、無料でできる仕掛けの試運転を分けて扱います。修理したあとの帰り道、2つまで持てる記念品と、1回だけの補給を確かめ、入力を再生して戻す Undo と、保存を作ります。

完成版の遊び方仕掛けを直して開いた道を自分の旅に使い、町の暮らしと、2つの記念品に足あとを残します。

旅する修理屋の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Aの森町で、歯車の試運転が終わった場面です。自動操作GIFから画素を変えず取り出した一コマで、人の試遊ではありません。教材は四つの町の移動から始め、04以降で森の機構を扱います。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:Tab地図・町・工具、矢印選択/Enter予告・試運転・確定、X戻す、S保存/L読込

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

無料の試運転と、帰り道を守る修理の確定を分ける。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. 点と道のつながり(グラフ)・移動にかかる時間・予告と確定
2. 無料の調整・試運転の時計・キーを離すこと
3. 材料・修理したあとの帰り道・比較
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python の変数・if・for・関数・tuple・dict / dataclass/replace は01で説明します / 集合、剰余(割った余り)、ビット演算、JSON、例外(エラー)は段階を追って読みます
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Space/Enter:表紙で開始 / 1/2/3:表紙でA/B/C。04以降の森工具は歯車辺切替 / B:03以降、表紙で交通比較 / Tab:地図/町/未修理工具 / 左右:地図の行先、町の候補、工具の方法 / 上下:工具の位相/停止位置、町の置く品 / Enter:行動予告→確定。工具は試験→予告→確定 / Z:未修理の機構へ / X:確認取消、05で一意味入力Undo / H/P:説明/停止。試験時計も停止 / S/L:05で作業フォルダーへ保存/読込 / R/M/Esc:再試行/表紙/終了の確認。離してEnter確定
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_journey.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

四つの町を、残り時間で結ぶ

点と道のつながり(グラフ)・移動にかかる時間・予告と確定。

前提は、変数、if、for、関数、tuple と dict です。町を、H=0(拠点)、R=1(川)、K=2(丘)、F=3(森)の4つの番号で表します。roads は、両はしの番号の組をキー、必要な旅刻を値にした辞書です。最初の道は、H–R、H–K、R–F、K–F です。番号を sorted で並べてから探すので、行きと帰りは同じ時間です。状況 A は20刻、B は18刻、C は16刻です。道の時間も、元のゲームのそれぞれの配置のデータを使います。

この段階には、修理、材料、品物、履歴はありません。State は、今いる町、残りの時間、行った町の印、終わったかどうかだけを持ちます。dataclass(frozen=True) で記録を固定し、step は replace で新しい記録を返します。動く前に、番号の型、まっすぐつながっているか、残りの時間を確かめます。bool は int の仲間ですが、町の番号としては受け付けません。拠点で finish(旅を終える)を確定すると、練習の旅が終わります。元のゲームの「修理の達成」を判定する段階ではありません。

表紙で Space を押して離すと始まります。地図の左右キーは、行き先を選ぶキーです。Enter を押して離すと予告が開き、CURRENT(今)と AFTER CONFIRM(確定したあと)を比べられます。もう一度 Enter を押して離して、初めて移動します。X で取り消せば、何も減りません。移動すると町の画面になるので、次に移動するときは Tab で MAP(地図)に戻します。shortest は、今の町から拠点までの一番短い時間と、通る町の順番を返します。移動そのものは、道1本ぶんの時間だけを調べ、帰り道の分まで必ず残すという決まりはありません。帰り道の時間が足りないときは、注意を表示します。01 には Undo がないので、試したことを戻すときは、R の確認で最初からやり直します。

下のコードは 01_journey.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_journey.py / 注目する部分
        cost=distance(s,s.town,dst)
        if cost is None or s.time<cost:raise ValueError('no road or insufficient time')
        visited=list(s.visited);visited[dst]=True

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_journey.py
macOS / Linux
.venv/bin/python 01_journey.py
画面で見ること状況 A の最初は H、TIME=20 です。Space を離す → Enter を離す で予告だけが開き、CURRENT=20、AFTER=18 です。Enter を離して確定すると R、TIME=18、HOME COST=2(帰るのに2刻)です。Tab を押して離して MAP に戻すと行き先 H が選ばれ、Enter を2回(毎回離す)押すと H、TIME=16 です。
  • 道は、行きも帰りも同じ1本
  • 予告している間は、時間は変わらない
  • 移動したあとは、町の画面に切り替わる
つまずきやすいところ一番短い帰り道の合計と、1回の移動の時間を混同しないでください。まっすぐつながっていない H→F は、最初は選べません。Enter の押しっぱなしで、確認と実行を一度に済ませてしまわない作りです。
少し変えてみよう

INITIAL の A の (20,4,(2,3,3,2)) を (8,4,(2,3,3,2)) に変えてみましょう。UTF-8 で保存 → Esc を押して離す → Enter を押して離す で終了 → python 01_journey.py で実行し直します。Space を離す → Enter を離す で CURRENT=8 のまま、確定の Enter を離すと R で6です。Tab を離して MAP、行き先 H のまま Enter を2回、毎回離すと H で4です。python exercises/01_edit_control.py で、書き換えと同じ確認の操作を実行できます。最後に20に戻します。R はコードを読み直しません。

01_journey.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(354行)
01_journey.py / 全文
# 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 旅する修理屋 / 教材 1。"""
STAGE = 1

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'
    town: int = 0
    time: int = 20
    visited: tuple = (True,False,False,False)
    ended: bool = False
def new(case='A',transport=True):
    if case not in INITIAL:raise ValueError('unknown case')
    return State(case=case,time=INITIAL[case][0])
def roads(s):
    costs=INITIAL[s.case][2]
    return {(0,1):costs[0],(0,2):costs[1],(1,3):costs[2],(2,3):costs[3]}
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<ds[v] or (ds[u]+cost==ds[v] and path<paths[v]):ds[v]=ds[u]+cost;paths[v]=path
 return ds[start],paths[start]
def step(s,a):
    a=tuple(a)
    if s.ended:raise ValueError('journey ended')
    if not a:raise ValueError('empty action')
    if a[0]=='move' and len(a)==2:
        dst=a[1]
        if type(dst) is not int or dst not in range(4):raise ValueError('invalid town')
        cost=distance(s,s.town,dst)
        if cost is None or s.time<cost:raise ValueError('no road or insufficient time')
        visited=list(s.visited);visited[dst]=True
        return replace(s,town=dst,time=s.time-cost,visited=tuple(visited))
    if a==('finish',) and s.town==0:return replace(s,ended=True)
    raise ValueError('action unavailable')


KEYS={'SPACE','RETURN','X','P','H','R','M','UP','DOWN','LEFT','RIGHT','1','2','3','B','ESCAPE','TAB','Z'}
class Controller:
    def __init__(self,path=None):
        self.s=new();self.title=True;self.help=False;self.paused=False
        self.previous=set();self.gate=False;self.normalize()
    def normalize(self):
        self.view='map';self.destination=0 if self.s.town else 1
        self.choice=0;self.method='gear';self.dialog='';self.pending=None
        self.testing=False;self.test_frame=0;self.tested=None;self.notice=''
    def reset(self,case=None):
        self.s=new(case or self.s.case,getattr(self.s,'transport',True));self.normalize()
    def signature(self):return (self.s.town,self.method,getattr(self.s,'offset',2))
    def mechanism(self):return 0
    def options(self):
        out=[]
        if STAGE>=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<<i) else 5)
                    p.text((x+xx)//2,(y+yy)//2-12,str(i+1),7)
                for i,(x,y) in enumerate(coords):
                    p.circ(x,y,22,9);p.circ(x,y,13,1)
                    p.text(x-17,y+32,('D','A','LOAD')[i]+' '+{1:'+1',-1:'-1',0:'STOP'}[result['signs'][i]],7)
                p.text(30,108,'MASK '+str(s.edges)+' / 1 2 3: TOGGLE EDGES',7)
                p.text(30,300,'ODD CYCLE: STOP' if result['conflict'] else 'LOAD -1: PASS' if result['passes'] else 'NOT READY: LOAD MUST BE -1',8 if result['conflict'] else 10)
                p.text(30,338,'SIGNS SHOW CONSTRAINTS, NOT TRAVEL SPEED',6)
        else:
            p.rectb(16,70,430,305,5)
            for (a,b),cost in roads(s).items():
                x,y=POINTS[a];xx,yy=POINTS[b];c=10 if (a,b) in ((1,2),(0,3)) else 6
                p.line(x,y,xx,yy,c);mx=(x+xx)//2;my=(y+yy)//2
                if (a,b)==(1,2):my-=35
                if (a,b)==(0,3):mx+=45
                p.rect(mx-10,my-8,20,17,1);p.text(mx-2,my-2,str(cost),c)
            for i,(x,y) in enumerate(POINTS):
                p.circ(x,y,17,3 if s.visited[i] else 5);p.text(x-2,y-2,'HRKF'[i],7)
                if i==s.town:p.circb(x,y,22,10)
                if u.view=='map' and i==u.destination and not s.ended:p.rectb(x-27,y-27,55,55,7)
                p.text(x-22,y+34,('HOME','RIVER','HILL','FOREST')[i],7)
            p.text(30,353,'INITIAL ROADS / COST SHOWN ON EACH EDGE' if STAGE<3 else 'YELLOW EDGE = OPEN REPAIR ROUTE, BOTH DIRECTIONS',6)
        x=470;p.text(x,78,'CURRENT LEDGER',10)
        home,path=shortest(s);p.text(x,98,'HOME COST '+str(home)+'  PATH '+'>'.join('HRKF'[n] for n in path),11)
        if s.time<home:p.text(x,117,'WARNING: NOT ENOUGH TIME TO RETURN',8)
        if STAGE>=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<return_cost:lines.append('WARNING: RETURN TIME IS SHORT; UNDO IN STAGE 05')
                if u.pending[0]=='repair':lines.append('AFTER CONFIRM ROAD OPENS' if future.transport else 'COMPARISON: LIFE CHANGES, NO EXTRA TRAVEL EDGE')
                lines+=['PREVIEW ONLY: CURRENT STATE HAS NOT CHANGED','RELEASE / ENTER ONCE TO COMMIT / X CANCEL']
            for i,line in enumerate(lines):p.text(70,112+i*35,line,10 if i in (0,len(lines)-1) else 7)

def demo(app):
    u=app.ui;tap(u,'SPACE');go(u,1)
    if STAGE>=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)
STEP 02 / 05

旅を止めたまま、周期四のカムを試す

無料の調整・試運転の時計・キーを離すこと。

前提は、01 と、剰余の計算 %、True/False、前回押していたキーの集まりです。川の町だけに、道具の画面を加えます。R に着いたら Z を押して離すか、Tab で TOOLS(道具)に移ります。左のカムは t%4、右は (t+offset)%4 です。左右が同じ形であるだけでは足りず、同じ試運転の刻に、両方が0のときに出発できます。offset=0 なら1周に1回、ほかのずらしでは一度も出発しません。

上下キーで offset を0〜3の範囲でぐるぐる変えます。試運転は Enter を押して離すと始まり、Controller.feed が有効な状態で120回呼ばれると終わります。ふつうの30fpsでは約4秒で、画面には30回ずつ、4つの試運転の段階を見せます。これは旅の時間 time ではなく、画面用の test_frame です。試運転も調整も、旅の時間を使いません。この段階にはまだ修理の確定がなく、試運転が終わったあと Enter を押すと、もう一度試運転します。

H の説明、P の一時停止、確認の画面、表紙が開いている間は、試運転の時計を進めません。閉じる・戻るキーを離してから、次を押します。終わった瞬間に gate を立てて、終わる前から押していた Enter が、次の操作にならないようにします。試運転が終わっても、手を離さなければ、もう一度の試運転は始まりません。画面の4行は仕掛けの計算の例で、4刻ぶんの旅の時間や、この先の移動の予定ではありません。

下のコードは 02_free_trial.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_free_trial.py / 注目する部分
def ferry_trial(offset):return tuple((t%4,(t+offset)%4,t%4==0 and (t+offset)%4==0) for t in range(4))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_free_trial.py
macOS / Linux
.venv/bin/python 02_free_trial.py
画面で見ること状況 A で Space、Enter、Enter を毎回離して押し、R へ行きます。Z を離す → 下を2回、毎回離して offset=0 にします。Enter を押して離し、TEST DONE: PASS(試運転合格)まで待ちます。TIME=18 のままです。PHASE 0 だけが DEPART(出発)、ほかは WAIT(待つ)です。
  • 両方が0になる瞬間を調べる
  • 旅の時間と、試運転の時計を分ける
  • 終わったあとも、一度キーを全部離す
つまずきやすいところ試運転の120フレームを、State.time から引かないでください。H/P で止めた試運転の時計と、移動で減る旅の時間は別のものです。入力の判定には、前回との違いだけでなく、キーが全部離されるのを待つ gate も必要です。
少し変えてみよう

State の offset: int = 2 を offset: int = 0 に変えてみましょう。UTF-8 で保存 → Esc を離す → Enter を離す で終了 → python 02_free_trial.py で実行し直します。Space を離す → Enter を離す → Enter を離す で R へ。Z を押して離します。上下で調整せずに Enter を押して離すと、有効な feed が120回呼ばれたあと PASS です。TIME は18のままです。python exercises/02_edit_control.py は、feed を120回実行します。確認したら2に戻して、保存・起動し直しをします。R ではコードは読み直されません。

02_free_trial.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(360行)
02_free_trial.py / 全文
# 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 旅する修理屋 / 教材 2。"""
STAGE = 2

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'
    town: int = 0
    time: int = 20
    visited: tuple = (True,False,False,False)
    offset: int = 2
    ended: bool = False
def new(case='A',transport=True):
    if case not in INITIAL:raise ValueError('unknown case')
    return State(case=case,time=INITIAL[case][0])
def roads(s):
    costs=INITIAL[s.case][2]
    return {(0,1):costs[0],(0,2):costs[1],(1,3):costs[2],(2,3):costs[3]}
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<ds[v] or (ds[u]+cost==ds[v] and path<paths[v]):ds[v]=ds[u]+cost;paths[v]=path
 return ds[start],paths[start]

def ferry_trial(offset):return tuple((t%4,(t+offset)%4,t%4==0 and (t+offset)%4==0) for t in range(4))
def step(s,a):
    a=tuple(a)
    if s.ended:raise ValueError('journey ended')
    if not a:raise ValueError('empty action')
    if a[0]=='move' and len(a)==2:
        dst=a[1]
        if type(dst) is not int or dst not in range(4):raise ValueError('invalid town')
        cost=distance(s,s.town,dst)
        if cost is None or s.time<cost:raise ValueError('no road or insufficient time')
        visited=list(s.visited);visited[dst]=True
        return replace(s,town=dst,time=s.time-cost,visited=tuple(visited))
    if a[0]=='offset' and len(a)==2:
        if s.town!=1 or type(a[1]) is not int or a[1] not in range(4):raise ValueError('offset unavailable')
        return replace(s,offset=a[1])
    if a==('finish',) and s.town==0:return replace(s,ended=True)
    raise ValueError('action unavailable')


KEYS={'SPACE','RETURN','X','P','H','R','M','UP','DOWN','LEFT','RIGHT','1','2','3','B','ESCAPE','TAB','Z'}
class Controller:
    def __init__(self,path=None):
        self.s=new();self.title=True;self.help=False;self.paused=False
        self.previous=set();self.gate=False;self.normalize()
    def normalize(self):
        self.view='map';self.destination=0 if self.s.town else 1
        self.choice=0;self.method='gear';self.dialog='';self.pending=None
        self.testing=False;self.test_frame=0;self.tested=None;self.notice=''
    def reset(self,case=None):
        self.s=new(case or self.s.case,getattr(self.s,'transport',True));self.normalize()
    def signature(self):return (self.s.town,self.method,getattr(self.s,'offset',2))
    def mechanism(self):return 0
    def options(self):
        out=[]
        if STAGE>=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<<i) else 5)
                    p.text((x+xx)//2,(y+yy)//2-12,str(i+1),7)
                for i,(x,y) in enumerate(coords):
                    p.circ(x,y,22,9);p.circ(x,y,13,1)
                    p.text(x-17,y+32,('D','A','LOAD')[i]+' '+{1:'+1',-1:'-1',0:'STOP'}[result['signs'][i]],7)
                p.text(30,108,'MASK '+str(s.edges)+' / 1 2 3: TOGGLE EDGES',7)
                p.text(30,300,'ODD CYCLE: STOP' if result['conflict'] else 'LOAD -1: PASS' if result['passes'] else 'NOT READY: LOAD MUST BE -1',8 if result['conflict'] else 10)
                p.text(30,338,'SIGNS SHOW CONSTRAINTS, NOT TRAVEL SPEED',6)
        else:
            p.rectb(16,70,430,305,5)
            for (a,b),cost in roads(s).items():
                x,y=POINTS[a];xx,yy=POINTS[b];c=10 if (a,b) in ((1,2),(0,3)) else 6
                p.line(x,y,xx,yy,c);mx=(x+xx)//2;my=(y+yy)//2
                if (a,b)==(1,2):my-=35
                if (a,b)==(0,3):mx+=45
                p.rect(mx-10,my-8,20,17,1);p.text(mx-2,my-2,str(cost),c)
            for i,(x,y) in enumerate(POINTS):
                p.circ(x,y,17,3 if s.visited[i] else 5);p.text(x-2,y-2,'HRKF'[i],7)
                if i==s.town:p.circb(x,y,22,10)
                if u.view=='map' and i==u.destination and not s.ended:p.rectb(x-27,y-27,55,55,7)
                p.text(x-22,y+34,('HOME','RIVER','HILL','FOREST')[i],7)
            p.text(30,353,'INITIAL ROADS / COST SHOWN ON EACH EDGE' if STAGE<3 else 'YELLOW EDGE = OPEN REPAIR ROUTE, BOTH DIRECTIONS',6)
        x=470;p.text(x,78,'CURRENT LEDGER',10)
        home,path=shortest(s);p.text(x,98,'HOME COST '+str(home)+'  PATH '+'>'.join('HRKF'[n] for n in path),11)
        if s.time<home:p.text(x,117,'WARNING: NOT ENOUGH TIME TO RETURN',8)
        if STAGE>=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<return_cost:lines.append('WARNING: RETURN TIME IS SHORT; UNDO IN STAGE 05')
                if u.pending[0]=='repair':lines.append('AFTER CONFIRM ROAD OPENS' if future.transport else 'COMPARISON: LIFE CHANGES, NO EXTRA TRAVEL EDGE')
                lines+=['PREVIEW ONLY: CURRENT STATE HAS NOT CHANGED','RELEASE / ENTER ONCE TO COMMIT / X CANCEL']
            for i,line in enumerate(lines):p.text(70,112+i*35,line,10 if i in (0,len(lines)-1) else 7)

def demo(app):
    u=app.ui;tap(u,'SPACE');go(u,1)
    if STAGE>=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)
STEP 03 / 05

修理費を払い、帰れる道を開く

材料・修理したあとの帰り道・比較。

前提は、02 と、「状態を写して、この先を試してみる」という考え方です。材料と fixed(修理済み)を加え、川の渡し船を直せるようにします。森の仕掛け、丘の補給、記念品はまだありません。修理の方法は、gear(歯車を取りかえる)が材料2・旅1刻、hand(手回しの取っ手)が材料1・旅2刻です。道具の画面の左右キーで切り替えます。方法を変えたり、ずらしを編集したりすると、前の試運転の結果は無効になり、もう一度試運転が必要です。

試運転が PASS になったあと、キーを全部離して Enter を押すと、修理の予告です。まだ材料は減りません。離してもう一度 Enter を押すと、1回だけ確定します。repair_forecast は、今いる町、まだ直していないか、方法、試運転に合格したか、材料、そして帰り道を、順番に調べます。大切なのは、fixed を修理済みにした future(この先の状態)を先に作ってから、shortest を呼ぶところです。直した道を使えば帰れる修理を、古い道の時間で断ってはいけません。反対に、修理したあとの残り時間が帰り道の時間より少ないなら、確定できません。

ふつうの版では、R–K に、行き帰り1刻の道が加わります。表紙の B で transport を False にして始めると、新しい移動の道だけを開かない比較になります。03 には、まだ暮らしの記録はなく、それは 04 で加えます。手回しは、材料を節約するかわりに時間を多く使う選び方で、隠れた罰や住民の評価はありません。拠点で finish を確定し、修理が1つ以上あれば SUCCESS です。修理せずに終えた旅も記録としては有効ですが、基本の目標は達成していません。

下のコードは 03_ferry_repair.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_ferry_repair.py / 注目する部分
 fixed=list(s.fixed);fixed[mechanism]=method;future=replace(s,fixed=tuple(fixed));home,path=shortest(future)
 if s.time-time<home:raise ValueError('この修理後は帰りに'+str(home-(s.time-time))+'刻足りません')

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_ferry_repair.py
macOS / Linux
.venv/bin/python 03_ferry_repair.py
画面で見ること状況 A で R へ移動すると、TIME=18、材料4 です。Z、下、下を毎回離して offset=0 にします。Enter を離して試運転が終わるのを待ち、Enter を離して修理の予告、もう一度 Enter を離して確定します。TIME=17、材料2、FIXED=(gear,空) で、R–K の黄色い道の時間は1です。
  • 試運転の結果は、方法と設定に結びついている
  • 修理したあとの道で、帰り道を調べる
  • 予告と確定で、二重に払わない
つまずきやすいところ試運転の PASS だけでは、修理は完了しません。「試運転 → 予告 → 確定」の3回の Enter には、それぞれキーを離すことが必要です。帰り道は、修理したあとの道で計算します。
少し変えてみよう

METHODS の 'hand':(1,2) を 'hand':(1,1) に変えてみましょう。UTF-8 で保存 → Esc を離す → Enter を離す で終了 → python 03_ferry_repair.py で実行し直します。Space → Enter → Enter で R へ(毎回離す)。Z → 下 → 下 → 左 で HAND に(毎回離す)。Enter を離して、試運転が終わるまで待ちます。Enter を離して予告を開き、CURRENT=18・材料4 を確かめます。さらに Enter を離して確定すると、TIME=17、材料3、FIXED=(hand,空) です。python exercises/03_edit_control.py で、同じ書き換えと確認を実行できます。最後に (1,2) に戻します。R はコードを読み直しません。

03_ferry_repair.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(388行)
03_ferry_repair.py / 全文
# 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<ds[v] or (ds[u]+cost==ds[v] and path<paths[v]):ds[v]=ds[u]+cost;paths[v]=path
 return ds[start],paths[start]

def ferry_trial(offset):return tuple((t%4,(t+offset)%4,t%4==0 and (t+offset)%4==0) for t in range(4))

def success(s):return s.ended and s.town==0 and any(s.fixed)
def repair_forecast(s,mechanism,method):
 if s.ended or mechanism not in (0,) or s.town!=(1 if mechanism==0 else 3):raise ValueError('ここでは、その修理はできません')
 if s.fixed[mechanism]:raise ValueError('この機構は修理済みです')
 if method not in (('gear','hand') if mechanism==0 else ('gear','rope')):raise ValueError('修理法が不正です')
 if not trial(s,mechanism,method)['passes']:raise ValueError('試運転で便が動くように調整してください')
 mat,time=METHODS[method]
 if s.materials<mat:raise ValueError('工具資材が足りません')
 fixed=list(s.fixed);fixed[mechanism]=method;future=replace(s,fixed=tuple(fixed));home,path=shortest(future)
 if s.time-time<home:raise ValueError('この修理後は帰りに'+str(home-(s.time-time))+'刻足りません')
 return {'materials':mat,'time':time,'remaining':s.time-time,'home':home,'path':path,'new_edge':(1,2) if mechanism==0 else (0,3),'opens':s.transport}
def step(s,a):
    a=tuple(a)
    if s.ended:raise ValueError('journey ended')
    if not a:raise ValueError('empty action')
    if a[0]=='move' and len(a)==2:
        dst=a[1]
        if type(dst) is not int or dst not in range(4):raise ValueError('invalid town')
        cost=distance(s,s.town,dst)
        if cost is None or s.time<cost:raise ValueError('no road or insufficient time')
        visited=list(s.visited);visited[dst]=True
        return replace(s,town=dst,time=s.time-cost,visited=tuple(visited))
    if a[0]=='offset' and len(a)==2:
        if s.town!=1 or s.fixed[0] or type(a[1]) is not int or a[1] not in range(4):raise ValueError('offset unavailable')
        return replace(s,offset=a[1])
    if a[0]=='repair' and len(a)==3:
        mechanism,method=a[1:]
        if type(mechanism) is not int:raise ValueError('invalid mechanism')
        f=repair_forecast(s,mechanism,method)
        fixed=list(s.fixed);fixed[mechanism]=method
        return replace(s,fixed=tuple(fixed),time=s.time-f['time'],materials=s.materials-f['materials'])
    if a==('finish',) and s.town==0:return replace(s,ended=True)
    raise ValueError('action unavailable')


KEYS={'SPACE','RETURN','X','P','H','R','M','UP','DOWN','LEFT','RIGHT','1','2','3','B','ESCAPE','TAB','Z'}
class Controller:
    def __init__(self,path=None):
        self.s=new();self.title=True;self.help=False;self.paused=False
        self.previous=set();self.gate=False;self.normalize()
    def normalize(self):
        self.view='map';self.destination=0 if self.s.town else 1
        self.choice=0;self.method='gear';self.dialog='';self.pending=None
        self.testing=False;self.test_frame=0;self.tested=None;self.notice=''
    def reset(self,case=None):
        self.s=new(case or self.s.case,getattr(self.s,'transport',True));self.normalize()
    def signature(self):return (self.s.town,self.method,getattr(self.s,'offset',2))
    def mechanism(self):return 0
    def options(self):
        out=[]
        if STAGE>=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<<i) else 5)
                    p.text((x+xx)//2,(y+yy)//2-12,str(i+1),7)
                for i,(x,y) in enumerate(coords):
                    p.circ(x,y,22,9);p.circ(x,y,13,1)
                    p.text(x-17,y+32,('D','A','LOAD')[i]+' '+{1:'+1',-1:'-1',0:'STOP'}[result['signs'][i]],7)
                p.text(30,108,'MASK '+str(s.edges)+' / 1 2 3: TOGGLE EDGES',7)
                p.text(30,300,'ODD CYCLE: STOP' if result['conflict'] else 'LOAD -1: PASS' if result['passes'] else 'NOT READY: LOAD MUST BE -1',8 if result['conflict'] else 10)
                p.text(30,338,'SIGNS SHOW CONSTRAINTS, NOT TRAVEL SPEED',6)
        else:
            p.rectb(16,70,430,305,5)
            for (a,b),cost in roads(s).items():
                x,y=POINTS[a];xx,yy=POINTS[b];c=10 if (a,b) in ((1,2),(0,3)) else 6
                p.line(x,y,xx,yy,c);mx=(x+xx)//2;my=(y+yy)//2
                if (a,b)==(1,2):my-=35
                if (a,b)==(0,3):mx+=45
                p.rect(mx-10,my-8,20,17,1);p.text(mx-2,my-2,str(cost),c)
            for i,(x,y) in enumerate(POINTS):
                p.circ(x,y,17,3 if s.visited[i] else 5);p.text(x-2,y-2,'HRKF'[i],7)
                if i==s.town:p.circb(x,y,22,10)
                if u.view=='map' and i==u.destination and not s.ended:p.rectb(x-27,y-27,55,55,7)
                p.text(x-22,y+34,('HOME','RIVER','HILL','FOREST')[i],7)
            p.text(30,353,'INITIAL ROADS / COST SHOWN ON EACH EDGE' if STAGE<3 else 'YELLOW EDGE = OPEN REPAIR ROUTE, BOTH DIRECTIONS',6)
        x=470;p.text(x,78,'CURRENT LEDGER',10)
        home,path=shortest(s);p.text(x,98,'HOME COST '+str(home)+'  PATH '+'>'.join('HRKF'[n] for n in path),11)
        if s.time<home:p.text(x,117,'WARNING: NOT ENOUGH TIME TO RETURN',8)
        if STAGE>=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<return_cost:lines.append('WARNING: RETURN TIME IS SHORT; UNDO IN STAGE 05')
                if u.pending[0]=='repair':lines.append('AFTER CONFIRM ROAD OPENS' if future.transport else 'COMPARISON: LIFE CHANGES, NO EXTRA TRAVEL EDGE')
                lines+=['PREVIEW ONLY: CURRENT STATE HAS NOT CHANGED','RELEASE / ENTER ONCE TO COMMIT / X CANCEL']
            for i,line in enumerate(lines):p.text(70,112+i*35,line,10 if i in (0,len(lines)-1) else 7)

def demo(app):
    u=app.ui;tap(u,'SPACE');go(u,1)
    if STAGE>=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)
STEP 04 / 05

森の機構と、置き場所を持つ二品

回る向きの符号が伝わること・2つの枠・1回だけの補給。

前提は、03 と、ビット演算、tuple の中の値で「どこにあるか」を表すことです。元のゲームの State と step をここで取り入れ、森、町の暮らし、品物、丘の補給を加えます。歯車のつながりは edges の3けたのビットで、1/2/3 キーで D–A、A–荷台、D–荷台 をつないだり外したりします。D は +1 で、かみ合う道ごとに符号が反対になり、荷台が −1 のときに便が動きます。3本全部をつなぐと、奇数本の輪(奇数閉路)で食い違いが起きて止まります。最初の mask=3 では荷台が +1 なので失敗です。道具の画面で 1、2、3 を1回ずつ押して離すと、3→2→0→4 になり、D と荷台が直接つながって荷台 −1 です。

左右キーで rope(ロープ)に替えた場合は、上下キーで stop=1 にします。ロープは材料1・旅2刻、gear は材料2・旅1刻です。試運転120回、あらためて予告、あらためて確定、の順番は川と同じです。森の修理で、H–F に1刻の便が開きます。暮らしは、渡し船で FISH(魚)、ロープウェイで PLANTS(植物)、両方で BELL(鐘)が記録されます。比較版でもこの暮らしの記録は変わり、自分が使える新しい移動の道だけが増えません。

gifts は、3つの品物それぞれの場所です。最初の (1,2,3) は、JAR が川、BELL が丘、TAG が森にあることを表します。−1 はかばんの中で、最大2つまでです。町の画面で左右キーで候補を選び、Enter で予告、離して Enter で取ります。いっぱいなら、上下キーで置いていく品物を選び、確定したときに今の町に残します。捨てて消すのではなく、戻れば取り戻せます。能力や点数は変わりません。丘の supply(補給)は無料で材料+1、1回だけです。無料の操作も、元のゲームの意味のある入力の history に記録しますが、この段階には Undo・保存の画面はまだありません。

下のコードは 04_town_inventory.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_town_inventory.py / 注目する部分
  if len(carried)==2:
   if type(old) is not int or old not in carried:raise ValueError('持帰りは2枠。置く一品を選んでください')
   g[old]=s.town

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_town_inventory.py
macOS / Linux
.venv/bin/python 04_town_inventory.py
画面で見ること状況 A で R へ行き、町の画面で右を1回離して JAR を選びます。Enter を2回、毎回離すと gifts=(-1,2,3) です。次に Tab を2回(町 → 道具 → 地図、毎回離す)で MAP、右を3回(0→1→2→3)で F を選び、Enter を2回で移動します。町の画面で右を1回 → Enter を2回で TAG を取ります。Tab を2回(町 → 道具 → 地図、毎回離す)で MAP、右を2回で K → Enter を2回。町で最初に選ばれている BELL を Enter 2回で取ると、最初に選ばれている JAR を K に置いて、gifts=(2,-1,-1)、TIME=13 です。キーは毎回全部離します。
  • 輪の食い違いと、止まることを区別する
  • 品物は、どこかの町か、かばんのどちらか1か所にある
  • 補給は無料だが、1回だけ
つまずきやすいところ品物の番号と、町の番号は別のものです。2つの枠がいっぱいのときは、old に、実際に持っている品物の番号が必要です。歯車の見た目の回転と、旅の時間も別です。奇数閉路の符号を順番に上書きして、通れることにしないでください。
少し変えてみよう

State の gifts:tuple=(1,2,3) を gifts:tuple=(2,1,3) に変えてみましょう。UTF-8 で保存 → Esc を離す → Enter を離す で終了 → python 04_town_inventory.py で実行し直します。Space → Enter → Enter で R へ(毎回離す)。町の画面で右を1回押して離します。候補が gift,1、BELL になったら、Enter を押して離して予告、もう一度 Enter を押して離して取ります。TIME=18 のまま、gifts=(2,-1,3) です。python exercises/04_edit_control.py で、書き換えた最初のデータと確認の操作を実行できます。最後に (1,2,3) に戻します。R はコードを読み直しません。

04_town_inventory.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(425行)
04_town_inventory.py / 全文
# 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 旅する修理屋 / 教材 4。"""
STAGE = 4

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); gifts:tuple=(1,2,3)
 supplied:bool=False; life:tuple=(); offset:int=2; edges:int=3; stop:int=0
 ended:bool=False; history:tuple=(); last:tuple=('止まった道を、次の旅に。',)

def new(case='A',transport=True):
 if case not in TITLES or type(transport) is not bool:raise ValueError('旅支度が不正です')
 t,m,_=INITIAL[case];return State(case=case,transport=transport,time=t,materials=m)

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<ds[v] or (ds[u]+cost==ds[v] and path<paths[v]):ds[v]=ds[u]+cost;paths[v]=path
 return ds[start],paths[start]

def ferry_trial(offset):return tuple((t%4,(t+offset)%4,t%4==0 and (t+offset)%4==0) for t in range(4))

def gear_trial(mask):
 signs={0:1};edges=[(0,1),(1,2),(0,2)];changed=True;conflict=False
 while changed:
  changed=False
  for i,(a,b) in enumerate(edges):
   if not mask&(1<<i):continue
   if a in signs and b in signs:
    if signs[a]!=-signs[b]:conflict=True
   elif a in signs:signs[b]=-signs[a];changed=True
   elif b in signs:signs[a]=-signs[b];changed=True
 return {'signs':tuple(signs.get(i,0) for i in range(3)),'conflict':conflict,'passes':not conflict and signs.get(2)==-1}

def trial(s,mechanism,method):
 if mechanism==0:return {'passes':any(t[2] for t in ferry_trial(s.offset)),'frames':ferry_trial(s.offset)}
 if method=='rope':return {'passes':s.stop==1,'stop':s.stop}
 return gear_trial(s.edges)

def repair_forecast(s,mechanism,method):
 if s.ended or mechanism not in (0,1) or s.town!=(1 if mechanism==0 else 3):raise ValueError('ここでは、その修理はできません')
 if s.fixed[mechanism]:raise ValueError('この機構は修理済みです')
 if method not in (('gear','hand') if mechanism==0 else ('gear','rope')):raise ValueError('修理法が不正です')
 if not trial(s,mechanism,method)['passes']:raise ValueError('試運転で便が動くように調整してください')
 mat,time=METHODS[method]
 if s.materials<mat:raise ValueError('工具資材が足りません')
 fixed=list(s.fixed);fixed[mechanism]=method;future=replace(s,fixed=tuple(fixed));home,path=shortest(future)
 if s.time-time<home:raise ValueError('この修理後は帰りに'+str(home-(s.time-time))+'刻足りません')
 return {'materials':mat,'time':time,'remaining':s.time-time,'home':home,'path':path,'new_edge':(1,2) if mechanism==0 else (0,3),'opens':s.transport}

def success(s):return s.ended and s.town==0 and any(s.fixed)

def step(s,a):
 a=tuple(a)
 if s.ended:raise ValueError('旅は終わっています。Xで一決定戻せます')
 if not a or type(a[0]) is not str:raise ValueError('操作が不正です')
 t=s;kind=a[0];msg=()
 if kind=='move' and len(a)==2:
  dst=a[1]
  if type(dst) is not int or dst not in range(4):raise ValueError('行き先が不正です')
  cost=distance(s,s.town,dst)
  if cost is None:raise ValueError('その二町を直接つなぐ道はありません')
  if s.time<cost:raise ValueError('移動の時間が足りません')
  visited=list(s.visited);visited[dst]=True;t=replace(s,town=dst,time=s.time-cost,visited=tuple(visited));msg=(TOWNS[s.town]+'から'+TOWNS[dst]+'へ '+str(cost)+'刻。','帰り道の最短所要 '+str(shortest(t)[0])+'刻。')
 elif kind=='repair' and len(a)==3:
  mech,method=a[1:]
  if type(mech) is not int:raise ValueError('機構番号が不正です')
  f=repair_forecast(s,mech,method);fixed=list(s.fixed);fixed[mech]=method
  life=tuple(k for k,p in enumerate((bool(fixed[0]),bool(fixed[1]),all(fixed))) if p)
  t=replace(s,fixed=tuple(fixed),time=s.time-f['time'],materials=s.materials-f['materials'],life=life)
  msg=(('渡し' if mech==0 else '索道')+'が動きました。資材 -'+str(f['materials'])+' / 旅 -'+str(f['time'])+'刻。',('新しい便を、次の移動に選べます。' if s.transport else '比較版: 町の荷物は動きますが、旅の道は増えません。'))
 elif kind=='supply' and len(a)==1:
  if s.town!=2 or s.supplied:raise ValueError('丘の工具は一度だけ受け取れます')
  t=replace(s,supplied=True,materials=s.materials+1);msg=('丘町の工房から、工具資材を1つ。','お礼は一度だけ。無料で受け取れます。')
 elif kind=='gift' and len(a)==3:
  item,old=a[1:]
  if type(item) is not int or item not in range(3) or s.gifts[item]!=s.town:raise ValueError('この町には、その品はありません')
  carried=[i for i,x in enumerate(s.gifts) if x==-1];g=list(s.gifts)
  if len(carried)==2:
   if type(old) is not int or old not in carried:raise ValueError('持帰りは2枠。置く一品を選んでください')
   g[old]=s.town
  elif old!=-1:raise ValueError('空き枠があるので、交換は不要です')
  g[item]=-1;t=replace(s,gifts=tuple(g));msg=(GIFTS[item]+'を、旅のかばんへ。',('置いた'+GIFTS[old]+'はこの町に残ります。' if len(carried)==2 else '品に能力や点数はありません。景色の記憶です。'))
 elif kind in ('offset','edges','stop') and len(a)==2:
  limit={'offset':4,'edges':8,'stop':3}[kind]
  if type(a[1]) is not int or a[1] not in range(limit):raise ValueError('機構の調整値が不正です')
  if s.town!=(1 if kind=='offset' else 3) or s.fixed[0 if kind=='offset' else 1]:raise ValueError('調整は未修理の町で行います')
  t=replace(s,**{kind:a[1]});msg=('調整は無料です。旅時間と資材は減りません。',)
 elif kind=='finish' and len(a)==1:
  if s.town!=0:raise ValueError('旅を終えるのは拠点です')
  t=replace(s,ended=True);msg=('おかえり。直した道と、選んだ二品。','町の暮らしに、旅の足あとが残りました。' if any(s.fixed) else '今回は下見の旅。修理のない記録も残せます。')
 else:raise ValueError('この操作は今できません')
 return replace(t,history=s.history+(a,),last=msg)

from pathlib import Path
KEYS={'SPACE','RETURN','Z','X','P','H','R','M','UP','DOWN','LEFT','RIGHT','1','2','3','B','F12','ESCAPE','TAB','S','L'}
class Controller:
 def __init__(self,path=None):
  self.s=new();self.title=True;self.help=False;self.paused=False;self.dialog='';self.previous=set();self.gate=False;self.view='map';self.destination=1;self.choice=0;self.method='gear';self.old=0;self.pending=None;self.notice='';self.testing=False;self.test_frame=0;self.tested=None;self.path=Path(path) if path else Path(__file__).parent/'save.json'
 def normalize(self):
  self.view='map';self.destination=0 if self.s.town else 1;self.choice=0;self.method='gear';self.old=0;self.dialog='';self.pending=None;self.testing=False;self.tested=None;self.test_frame=0;self.notice=''
 def reset(self,case=None):
  self.s=new(case or self.s.case,self.s.transport);self.normalize()
 def mechanism(self):return 0 if self.s.town==1 else 1
 def signature(self):return (self.s.town,self.method,self.s.offset,self.s.edges,self.s.stop)
 def options(self):
  s=self.s;out=[]
  if s.town in (1,3) and not s.fixed[self.mechanism()]:out.append(('機構を調べる',('tools',)))
  for i,x in enumerate(s.gifts):
   if x==s.town:out.append((GIFTS[i]+'を持帰る',('gift',i)))
  if s.town==2 and not s.supplied:out.append(('工具を1つ受取る',('supply',)))
  if s.town==0:out.append(('旅を終える',('finish',)))
  return out
 def apply(self,a):
  try:
   self.s=step(self.s,a);self.choice=0;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 k=='F12':return 'screenshot'
  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=='action':a=self.pending;self.pending=None;return self.apply(a)
    if d=='retry':self.reset();return 'reset'
    if d=='menu':self.title=True;return 'menu'
   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 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'
  if k=='TAB':
   views=('map','town','tools') if self.s.town in (1,3) and not self.s.fixed[self.mechanism()] 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 self.s.town in (1,3) and not self.s.fixed[self.mechanism()]: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':
   opts=self.options()
   if k in ('LEFT','RIGHT') and opts:self.choice=(self.choice+(-1 if k=='LEFT' else 1))%len(opts);return 'select-town'
   if k in ('UP','DOWN'):self.old=1-self.old;return 'select-replacement'
   if k=='RETURN' and opts:
    a=opts[self.choice%len(opts)][1]
    if a[0]=='tools':self.view='tools';return 'tools'
    if a[0]=='gift':carried=[i for i,x in enumerate(self.s.gifts) if x==-1];a=(*a,carried[self.old%2] if len(carried)==2 else -1)
    return self.preview(a)
  else:
   mech=self.mechanism()
   if k in ('LEFT','RIGHT'):self.method=('hand' if mech==0 else 'rope') if self.method=='gear' else 'gear';self.tested=None;return 'select-method'
   if k in ('UP','DOWN'):
    field='offset' if mech==0 else 'stop';n=getattr(self.s,field);return self.apply((field,(n+(1 if k=='UP' else -1))%(4 if mech==0 else 3)))
   if k in ('1','2','3') and mech==1 and self.method=='gear':return self.apply(('edges',self.s.edges^(1<<(int(k)-1))))
   if k=='RETURN':
    if self.tested==self.signature() and trial(self.s,mech,self.method)['passes']:return self.preview(('repair',mech,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<<i) else 5)
                    p.text((x+xx)//2,(y+yy)//2-12,str(i+1),7)
                for i,(x,y) in enumerate(coords):
                    p.circ(x,y,22,9);p.circ(x,y,13,1)
                    p.text(x-17,y+32,('D','A','LOAD')[i]+' '+{1:'+1',-1:'-1',0:'STOP'}[result['signs'][i]],7)
                p.text(30,108,'MASK '+str(s.edges)+' / 1 2 3: TOGGLE EDGES',7)
                p.text(30,300,'ODD CYCLE: STOP' if result['conflict'] else 'LOAD -1: PASS' if result['passes'] else 'NOT READY: LOAD MUST BE -1',8 if result['conflict'] else 10)
                p.text(30,338,'SIGNS SHOW CONSTRAINTS, NOT TRAVEL SPEED',6)
        else:
            p.rectb(16,70,430,305,5)
            for (a,b),cost in roads(s).items():
                x,y=POINTS[a];xx,yy=POINTS[b];c=10 if (a,b) in ((1,2),(0,3)) else 6
                p.line(x,y,xx,yy,c);mx=(x+xx)//2;my=(y+yy)//2
                if (a,b)==(1,2):my-=35
                if (a,b)==(0,3):mx+=45
                p.rect(mx-10,my-8,20,17,1);p.text(mx-2,my-2,str(cost),c)
            for i,(x,y) in enumerate(POINTS):
                p.circ(x,y,17,3 if s.visited[i] else 5);p.text(x-2,y-2,'HRKF'[i],7)
                if i==s.town:p.circb(x,y,22,10)
                if u.view=='map' and i==u.destination and not s.ended:p.rectb(x-27,y-27,55,55,7)
                p.text(x-22,y+34,('HOME','RIVER','HILL','FOREST')[i],7)
            p.text(30,353,'INITIAL ROADS / COST SHOWN ON EACH EDGE' if STAGE<3 else 'YELLOW EDGE = OPEN REPAIR ROUTE, BOTH DIRECTIONS',6)
        x=470;p.text(x,78,'CURRENT LEDGER',10)
        home,path=shortest(s);p.text(x,98,'HOME COST '+str(home)+'  PATH '+'>'.join('HRKF'[n] for n in path),11)
        if s.time<home:p.text(x,117,'WARNING: NOT ENOUGH TIME TO RETURN',8)
        if STAGE>=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<return_cost:lines.append('WARNING: RETURN TIME IS SHORT; UNDO IN STAGE 05')
                if u.pending[0]=='repair':lines.append('AFTER CONFIRM ROAD OPENS' if future.transport else 'COMPARISON: LIFE CHANGES, NO EXTRA TRAVEL EDGE')
                lines+=['PREVIEW ONLY: CURRENT STATE HAS NOT CHANGED','RELEASE / ENTER ONCE TO COMMIT / X CANCEL']
            for i,line in enumerate(lines):p.text(70,112+i*35,line,10 if i in (0,len(lines)-1) else 7)

def demo(app):
    u=app.ui;tap(u,'SPACE');go(u,1)
    if STAGE>=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)
STEP 05 / 05

一決定を戻し、再生一致で保存を読む

履歴・型まで厳しく比べる・元のゲームの Controller。

前提は、04 と、JSON、例外、型を中までくり返し比べることです。元のゲームの model.py の定数・関数すべて、State と Controller 全体を入れています。最後の画面は英数字(ASCII)の教材の版で、元のゲームの景色・日本語の文字・会話・くわしい最後の文・回転の演出・F12/自動の PNG/GIF/実行の記録は省いています。ルールの中の暮らしや品物の情報はそのまま持っていて、CURRENT LEDGER(今の記録)に英語で見せます。ふつうの更新では毎フレーム feed を呼び、120回で試運転を進めます。

X の undo は、time を足して戻すだけではありません。history の最後の意味のある入力を1つ取り除き、同じ case と transport から replay(再生)します。移動、offset/edges/stop の無料の調整、修理、品物の取得や交換、supply、finish が、それぞれ1回の判断です。試運転のフレーム、カーソルの変更、予告の画面は State.history に入らず、ゲームの判断ではありません。Undo のあとは、道具の方法などの一時的な画面の状態を normalize で最初に戻し、MAP に戻ります。終わったあとも、X で finish を取り消せます。

S は、起動したときの作業フォルダーの save_100.json に保存し、同じ名前の前のゲームの保存を更新します。L は、知らない版、余分なキー・足りないキー、型の違い、再生した結果と違う状態を受け付けません。restore は、履歴を最初から実行した expected と、すべての項目を厳しく比べます。Python では True==1 ですが、型が違えば、ここでは一致とみなしません。こわれた保存を勝手に消さず、今の状態をそのままにして、ACTION/IO REJECTED と表示します。正しく読みこめたときは MAP に戻り、試運転の印や、確認待ちの操作を残しません。

試運転が終わる直前から Enter を押していても、自動で修理を確定しません。「終わったときの待ち → 全部離す → 新しく Enter で予告 → 全部離す → 新しく Enter で確定」の順です。H/P は試運転の時計を止め、旅の時間は、調整・試運転・説明では、もともと減りません。R は最初の状態に戻すだけで、書き換えた Python を読み直しません。UTF-8 で保存して Esc を離し、Enter で終了してから実行し直します。

下のコードは 05_replay_save.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_replay_save.py / 注目する部分
def undo(s):return replay(s.case,s.transport,s.history[:-1]) if s.history else s

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_replay_save.py
macOS / Linux
.venv/bin/python 05_replay_save.py
画面で見ること状況 A で Space を離します。Enter を離して R への予告をしている間は、H、TIME=20、履歴0 です。もう一度 Enter を離すと、R、TIME=18、履歴1 です。X を離すと、H、TIME=20、履歴0 で、MAP に戻ります。S で保存したあとに移動し、L で読みこむと、同じ保存した状態に戻ります。
  • 無料の調整も、1回の判断
  • 履歴を全部再生して、型まで厳しく照らし合わせる
  • 完了や確認のたびに、キーを全部離す
つまずきやすいところ試運転の経過フレームを、旅の判断の履歴に混ぜないでください。読みこみのときの型の比較をただの == にすると、True と1の違いを見逃します。Undo で材料だけを戻しても、道・暮らし・品物の置き場所は元に戻せません。
少し変えてみよう

Controller.__init__ の self.view='map';self.destination=1; を self.view='map';self.destination=2; に変えてみましょう。UTF-8 で保存 → Esc を離す → Enter を離す で終了 → python 05_replay_save.py で実行し直します。Space を押して離す → Enter を押して離す と K への予告ですが、CURRENT は H、TIME=20 のままです。もう一度 Enter を押して離すと、K、TIME=17 です。X を押して離すと、H、TIME=20、履歴は空です。python exercises/05_edit_control.py で、書き換え・確認・Undo を実行できます。最後に1に戻して、保存・起動し直しをします。R ではコードの変更は反映されません。

05_replay_save.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(446行)
05_replay_save.py / 全文
# 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 旅する修理屋 / 教材 5。"""
STAGE = 5

"""Pure model: a tiny journey, repair mechanisms, return routes and visible town life."""
from dataclasses import dataclass,asdict,replace
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); gifts:tuple=(1,2,3)
 supplied:bool=False; life:tuple=(); offset:int=2; edges:int=3; stop:int=0
 ended:bool=False; history:tuple=(); last:tuple=('止まった道を、次の旅に。',)
def new(case='A',transport=True):
 if case not in TITLES or type(transport) is not bool:raise ValueError('旅支度が不正です')
 t,m,_=INITIAL[case];return State(case=case,transport=transport,time=t,materials=m)
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<ds[v] or (ds[u]+cost==ds[v] and path<paths[v]):ds[v]=ds[u]+cost;paths[v]=path
 return ds[start],paths[start]
def ferry_trial(offset):return tuple((t%4,(t+offset)%4,t%4==0 and (t+offset)%4==0) for t in range(4))
def gear_trial(mask):
 signs={0:1};edges=[(0,1),(1,2),(0,2)];changed=True;conflict=False
 while changed:
  changed=False
  for i,(a,b) in enumerate(edges):
   if not mask&(1<<i):continue
   if a in signs and b in signs:
    if signs[a]!=-signs[b]:conflict=True
   elif a in signs:signs[b]=-signs[a];changed=True
   elif b in signs:signs[a]=-signs[b];changed=True
 return {'signs':tuple(signs.get(i,0) for i in range(3)),'conflict':conflict,'passes':not conflict and signs.get(2)==-1}
def trial(s,mechanism,method):
 if mechanism==0:return {'passes':any(t[2] for t in ferry_trial(s.offset)),'frames':ferry_trial(s.offset)}
 if method=='rope':return {'passes':s.stop==1,'stop':s.stop}
 return gear_trial(s.edges)
def repair_forecast(s,mechanism,method):
 if s.ended or mechanism not in (0,1) or s.town!=(1 if mechanism==0 else 3):raise ValueError('ここでは、その修理はできません')
 if s.fixed[mechanism]:raise ValueError('この機構は修理済みです')
 if method not in (('gear','hand') if mechanism==0 else ('gear','rope')):raise ValueError('修理法が不正です')
 if not trial(s,mechanism,method)['passes']:raise ValueError('試運転で便が動くように調整してください')
 mat,time=METHODS[method]
 if s.materials<mat:raise ValueError('工具資材が足りません')
 fixed=list(s.fixed);fixed[mechanism]=method;future=replace(s,fixed=tuple(fixed));home,path=shortest(future)
 if s.time-time<home:raise ValueError('この修理後は帰りに'+str(home-(s.time-time))+'刻足りません')
 return {'materials':mat,'time':time,'remaining':s.time-time,'home':home,'path':path,'new_edge':(1,2) if mechanism==0 else (0,3),'opens':s.transport}
def success(s):return s.ended and s.town==0 and any(s.fixed)
def step(s,a):
 a=tuple(a)
 if s.ended:raise ValueError('旅は終わっています。Xで一決定戻せます')
 if not a or type(a[0]) is not str:raise ValueError('操作が不正です')
 t=s;kind=a[0];msg=()
 if kind=='move' and len(a)==2:
  dst=a[1]
  if type(dst) is not int or dst not in range(4):raise ValueError('行き先が不正です')
  cost=distance(s,s.town,dst)
  if cost is None:raise ValueError('その二町を直接つなぐ道はありません')
  if s.time<cost:raise ValueError('移動の時間が足りません')
  visited=list(s.visited);visited[dst]=True;t=replace(s,town=dst,time=s.time-cost,visited=tuple(visited));msg=(TOWNS[s.town]+'から'+TOWNS[dst]+'へ '+str(cost)+'刻。','帰り道の最短所要 '+str(shortest(t)[0])+'刻。')
 elif kind=='repair' and len(a)==3:
  mech,method=a[1:]
  if type(mech) is not int:raise ValueError('機構番号が不正です')
  f=repair_forecast(s,mech,method);fixed=list(s.fixed);fixed[mech]=method
  life=tuple(k for k,p in enumerate((bool(fixed[0]),bool(fixed[1]),all(fixed))) if p)
  t=replace(s,fixed=tuple(fixed),time=s.time-f['time'],materials=s.materials-f['materials'],life=life)
  msg=(('渡し' if mech==0 else '索道')+'が動きました。資材 -'+str(f['materials'])+' / 旅 -'+str(f['time'])+'刻。',('新しい便を、次の移動に選べます。' if s.transport else '比較版: 町の荷物は動きますが、旅の道は増えません。'))
 elif kind=='supply' and len(a)==1:
  if s.town!=2 or s.supplied:raise ValueError('丘の工具は一度だけ受け取れます')
  t=replace(s,supplied=True,materials=s.materials+1);msg=('丘町の工房から、工具資材を1つ。','お礼は一度だけ。無料で受け取れます。')
 elif kind=='gift' and len(a)==3:
  item,old=a[1:]
  if type(item) is not int or item not in range(3) or s.gifts[item]!=s.town:raise ValueError('この町には、その品はありません')
  carried=[i for i,x in enumerate(s.gifts) if x==-1];g=list(s.gifts)
  if len(carried)==2:
   if type(old) is not int or old not in carried:raise ValueError('持帰りは2枠。置く一品を選んでください')
   g[old]=s.town
  elif old!=-1:raise ValueError('空き枠があるので、交換は不要です')
  g[item]=-1;t=replace(s,gifts=tuple(g));msg=(GIFTS[item]+'を、旅のかばんへ。',('置いた'+GIFTS[old]+'はこの町に残ります。' if len(carried)==2 else '品に能力や点数はありません。景色の記憶です。'))
 elif kind in ('offset','edges','stop') and len(a)==2:
  limit={'offset':4,'edges':8,'stop':3}[kind]
  if type(a[1]) is not int or a[1] not in range(limit):raise ValueError('機構の調整値が不正です')
  if s.town!=(1 if kind=='offset' else 3) or s.fixed[0 if kind=='offset' else 1]:raise ValueError('調整は未修理の町で行います')
  t=replace(s,**{kind:a[1]});msg=('調整は無料です。旅時間と資材は減りません。',)
 elif kind=='finish' and len(a)==1:
  if s.town!=0:raise ValueError('旅を終えるのは拠点です')
  t=replace(s,ended=True);msg=('おかえり。直した道と、選んだ二品。','町の暮らしに、旅の足あとが残りました。' if any(s.fixed) else '今回は下見の旅。修理のない記録も残せます。')
 else:raise ValueError('この操作は今できません')
 return replace(t,history=s.history+(a,),last=msg)
def replay(case,transport,history):
 s=new(case,transport)
 for a in history:s=step(s,a)
 return s
def undo(s):return replay(s.case,s.transport,s.history[:-1]) if s.history else s
def digest(s):return hashlib.sha256(json.dumps(asdict(s),ensure_ascii=False,sort_keys=True).encode('utf-8')).hexdigest()
def restore(d):
 if not isinstance(d,dict) or set(d)!=set(asdict(new())) or type(d['history']) is not list:raise ValueError('保存の項目か履歴が不正です')
 if any(type(a) is not list or any(type(x) not in (str,int) for x in a) for a in d['history']):raise ValueError('操作記録が不正です')
 s=replay(d['case'],d['transport'],d['history']);expected=json.loads(json.dumps(asdict(s),ensure_ascii=False))
 def strict(a,b):
  if type(a) is not type(b):return False
  if isinstance(a,list):return len(a)==len(b) and all(strict(x,y) for x,y in zip(a,b))
  if isinstance(a,dict):return a.keys()==b.keys() and all(strict(a[k],b[k]) for k in a)
  return a==b
 if not strict(expected,d):raise ValueError('保存の型か再生結果が一致しません')
 return s

from pathlib import Path
KEYS={'SPACE','RETURN','Z','X','P','H','R','M','UP','DOWN','LEFT','RIGHT','1','2','3','B','F12','ESCAPE','TAB','S','L'}
class Controller:
 def __init__(self,path=None):
  self.s=new();self.title=True;self.help=False;self.paused=False;self.dialog='';self.previous=set();self.gate=False;self.view='map';self.destination=1;self.choice=0;self.method='gear';self.old=0;self.pending=None;self.notice='';self.testing=False;self.test_frame=0;self.tested=None;self.path=Path(path) if path else Path(__file__).parent/'save.json'
 def normalize(self):
  self.view='map';self.destination=0 if self.s.town else 1;self.choice=0;self.method='gear';self.old=0;self.dialog='';self.pending=None;self.testing=False;self.tested=None;self.test_frame=0;self.notice=''
 def reset(self,case=None):
  self.s=new(case or self.s.case,self.s.transport);self.normalize()
 def mechanism(self):return 0 if self.s.town==1 else 1
 def signature(self):return (self.s.town,self.method,self.s.offset,self.s.edges,self.s.stop)
 def options(self):
  s=self.s;out=[]
  if s.town in (1,3) and not s.fixed[self.mechanism()]:out.append(('機構を調べる',('tools',)))
  for i,x in enumerate(s.gifts):
   if x==s.town:out.append((GIFTS[i]+'を持帰る',('gift',i)))
  if s.town==2 and not s.supplied:out.append(('工具を1つ受取る',('supply',)))
  if s.town==0:out.append(('旅を終える',('finish',)))
  return out
 def apply(self,a):
  try:
   self.s=step(self.s,a);self.choice=0;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 save(self):
  d={'game':GAME,'version':VERSION,'state':asdict(self.s),'settings':{'transport':self.s.transport}}
  tmp=self.path.with_suffix('.tmp');tmp.write_text(json.dumps(d,ensure_ascii=False,indent=2),encoding='utf-8');tmp.replace(self.path)
 def load(self):
  d=json.loads(self.path.read_text(encoding='utf-8'))
  if not isinstance(d,dict) or set(d)!={'game','version','state','settings'} or d['game']!=GAME or type(d['version']) is not int or d['version']!=VERSION:raise ValueError('未知の保存版。Rで新規開始できます')
  s=restore(d['state'])
  if d['settings']!={'transport':s.transport} or type(d['settings'].get('transport')) is not bool:raise ValueError('保存の設定が不正です')
  self.s=s;self.title=False;self.normalize()
 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 k=='F12':return 'screenshot'
  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=='action':a=self.pending;self.pending=None;return self.apply(a)
    if d=='retry':self.reset();return 'reset'
    if d=='menu':self.title=True;return 'menu'
   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 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 k in ('S','L'):
   try:
    if k=='S':self.save();self.notice='保存しました'
    else:self.load();self.notice='読込みました'
    return 'save' if k=='S' else 'load'
   except (OSError,ValueError,TypeError,KeyError) as e:self.notice='保存を変更せず停止: '+str(e);return 'save-error'
  if k=='X':self.s=undo(self.s);self.normalize();return 'undo'
  if self.s.ended:return 'ignored:result'
  if k=='TAB':
   views=('map','town','tools') if self.s.town in (1,3) and not self.s.fixed[self.mechanism()] 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 self.s.town in (1,3) and not self.s.fixed[self.mechanism()]: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':
   opts=self.options()
   if k in ('LEFT','RIGHT') and opts:self.choice=(self.choice+(-1 if k=='LEFT' else 1))%len(opts);return 'select-town'
   if k in ('UP','DOWN'):self.old=1-self.old;return 'select-replacement'
   if k=='RETURN' and opts:
    a=opts[self.choice%len(opts)][1]
    if a[0]=='tools':self.view='tools';return 'tools'
    if a[0]=='gift':carried=[i for i,x in enumerate(self.s.gifts) if x==-1];a=(*a,carried[self.old%2] if len(carried)==2 else -1)
    return self.preview(a)
  else:
   mech=self.mechanism()
   if k in ('LEFT','RIGHT'):self.method=('hand' if mech==0 else 'rope') if self.method=='gear' else 'gear';self.tested=None;return 'select-method'
   if k in ('UP','DOWN'):
    field='offset' if mech==0 else 'stop';n=getattr(self.s,field);return self.apply((field,(n+(1 if k=='UP' else -1))%(4 if mech==0 else 3)))
   if k in ('1','2','3') and mech==1 and self.method=='gear':return self.apply(('edges',self.s.edges^(1<<(int(k)-1))))
   if k=='RETURN':
    if self.tested==self.signature() and trial(self.s,mech,self.method)['passes']:return self.preview(('repair',mech,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<<i) else 5)
                    p.text((x+xx)//2,(y+yy)//2-12,str(i+1),7)
                for i,(x,y) in enumerate(coords):
                    p.circ(x,y,22,9);p.circ(x,y,13,1)
                    p.text(x-17,y+32,('D','A','LOAD')[i]+' '+{1:'+1',-1:'-1',0:'STOP'}[result['signs'][i]],7)
                p.text(30,108,'MASK '+str(s.edges)+' / 1 2 3: TOGGLE EDGES',7)
                p.text(30,300,'ODD CYCLE: STOP' if result['conflict'] else 'LOAD -1: PASS' if result['passes'] else 'NOT READY: LOAD MUST BE -1',8 if result['conflict'] else 10)
                p.text(30,338,'SIGNS SHOW CONSTRAINTS, NOT TRAVEL SPEED',6)
        else:
            p.rectb(16,70,430,305,5)
            for (a,b),cost in roads(s).items():
                x,y=POINTS[a];xx,yy=POINTS[b];c=10 if (a,b) in ((1,2),(0,3)) else 6
                p.line(x,y,xx,yy,c);mx=(x+xx)//2;my=(y+yy)//2
                if (a,b)==(1,2):my-=35
                if (a,b)==(0,3):mx+=45
                p.rect(mx-10,my-8,20,17,1);p.text(mx-2,my-2,str(cost),c)
            for i,(x,y) in enumerate(POINTS):
                p.circ(x,y,17,3 if s.visited[i] else 5);p.text(x-2,y-2,'HRKF'[i],7)
                if i==s.town:p.circb(x,y,22,10)
                if u.view=='map' and i==u.destination and not s.ended:p.rectb(x-27,y-27,55,55,7)
                p.text(x-22,y+34,('HOME','RIVER','HILL','FOREST')[i],7)
            p.text(30,353,'INITIAL ROADS / COST SHOWN ON EACH EDGE' if STAGE<3 else 'YELLOW EDGE = OPEN REPAIR ROUTE, BOTH DIRECTIONS',6)
        x=470;p.text(x,78,'CURRENT LEDGER',10)
        home,path=shortest(s);p.text(x,98,'HOME COST '+str(home)+'  PATH '+'>'.join('HRKF'[n] for n in path),11)
        if s.time<home:p.text(x,117,'WARNING: NOT ENOUGH TIME TO RETURN',8)
        if STAGE>=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<return_cost:lines.append('WARNING: RETURN TIME IS SHORT; UNDO IN STAGE 05')
                if u.pending[0]=='repair':lines.append('AFTER CONFIRM ROAD OPENS' if future.transport else 'COMPARISON: LIFE CHANGES, NO EXTRA TRAVEL EDGE')
                lines+=['PREVIEW ONLY: CURRENT STATE HAS NOT CHANGED','RELEASE / ENTER ONCE TO COMMIT / X CANCEL']
            for i,line in enumerate(lines):p.text(70,112+i*35,line,10 if i in (0,len(lines)-1) else 7)

def demo(app):
    u=app.ui;tap(u,'SPACE');go(u,1)
    if STAGE>=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)
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

ルールのまとめ:修理を1つ以上確定して、拠点で旅を終える。旅の時間を使うのは、移動と修理だけ。無料の調整と試運転、2つまでの記念品、1回だけの補給。修理したあとの一番短い帰り道の時間は、必ず残る。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
段階01 旅だけ、02 川の試運転、03 川の修理、04 元のゲームの step と森・品物・補給、05 元のゲームのルール全部と Controller。最初から、すべての機能があります。
表示780×570の英数字(ASCII)。状態・道・仕掛け・予告・品物の場所を、数字で表示。1024×720の日本語の文字、景色、会話、動く歯車、暮らしの図。
記録save_100.json は、起動した作業フォルダーに保存。F12 は出来事として残すだけ。自動の PNG/GIF/実行の記録は省略。元のゲームのフォルダーの save.json と、実行ごとの画像などの記録。

完成版のコードで、次に読むところ

  1. model.py の roads/shortest:帰り道と、同じ距離のときの町の順番
  2. model.py の ferry_trial/gear_trial:それぞれ独立した試運転の計算
  3. model.py の repair_forecast:修理したあとの future
  4. model.py の step:gift/supply/life
  5. model.py の replay/undo/restore:厳しい比較
  6. controller.py の feed:120回の試運転と、終わったときの待ち(gate)
  7. controller.py の save/load/normalize
  8. app.py の mechanism:回転と、町の暮らしの描画
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る