探索・アドベンチャーLESSON 89有限放送枠と予約効果の状態シミュレーション原作070

帰れないラジオ局の作り方

聞いた声と、本当に起きた出来事を分けて考えます。
4つの放送枠(放送できる回数)で、「読む」「伝える」「待つ」を区別します。予約した効果、同じ時刻に起きる出来事の順番、まだ確かめていないことを残したまま放送を終える「閉局」、そして Undo(元に戻す)を作ります。

完成版の遊び方放送できるのは4回(4つの放送枠)だけです。今すぐ使える帰り道、あとで来る助け、今夜だけの声のどれを放送するかを選びます。最後に、残った仕事を次の担当へ引き継ぎます。

帰れないラジオ局の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の夜Aを、自動で動かして描いた画面です。下の教材は、読む操作と、数に限りのある放送枠から始めます。そのあと、予約、同じ時刻の処理、まだ確かめていない情報を、段階ごとに加えます。
PLAY / 試験公開

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

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

完成版の主な操作:上下キーで着信を選ぶ、左右キーで原稿・影響・地図を切り替える、Zで読む、Enterで放送する、Xで1枠戻す、Rでやり直し

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

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

うまく表示されないとき

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

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

予約した未来の出来事と、もう確かめた結果を分ける。

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

1. 書き換えない状態、キーを意味に変えた入力、回数に限りのある時計
2. 出来事(イベント)の列、到着の順番、条件がそろうと届く電話
3. priority(同じ時刻での順番)、下限で止める処理、過去に記録した値
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数 / tuple(タプル)と dict(辞書)、添字とスライス / dataclass・replace・イベントの優先順位は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
上下:着信を選ぶ / Z:読んだ印を付ける(時計は進めない) / Enter:選んだ声を放送する。handoff だけは、時計を進めずに放送を終える(閉局) / X:05だけ。直前の放送と、そのあとに読んだ印を Undo する / 1・2・3・4:01と02は夜Aだけ、03はAとB、04と05はA・B・C1・C2 / B:05の夜Aだけ。条件付きの見本を見せる比較に切り替えて、最初の状態に戻す / R:同じコードのまま、今の夜を最初の状態に戻す(コードは読み直さない) / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_read_and_send.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

読むだけの操作から、四枠の放送卓を作る

書き換えない状態、キーを意味に変えた入力、回数に限りのある時計。

前提は、変数、if、関数、tuple の末尾に追加する方法です。dataclass(frozen=True) は、できあがった状態を書き換えないための箱です。replace(s, read=...) は s をこわさずに、既読(読んだ印)だけを替えた新しい State を作ります。まずは、song(歌)と jingle(番組の合間の短い音楽)だけで動く、小さな放送卓を完成させます。上の段の TICK は、ゲームの中の時計(論理時計)です。Pyxel の描画のフレーム数ではありません。長く考えても、上下キーで選んでも、時間は進みません。

Z は read:カードID、Enter は send:カードID という文字列に変換されます。transition は、まず読む操作かどうかを調べます。読む操作なら、既読に追加してすぐに返します。放送だけが、t+1 と sent(放送した記録)を同時に更新します。4回放送すると ended=True になり、そのあとの入力では同じ状態を返します。選んだだけでも、原稿の要点は見えます。なので Z は、かくした本文を見せるための鍵ではありません。読んだ印を付ける操作です。

song を放送できるのは、枠0だけです。先に jingle を放送すると時計が1になり、song は song_late(遅れた歌)に差し替わります。song_late を放送しても、今夜の歌を紹介したことにはなりません。sent に入るIDを分けておくことで、「チャンスを逃した」という記録が残ります。jingle は何度でも放送できるので、4つの枠を必ず使い切れます。

この段階には、人の移動も予約もありません。画面の NO TRANSPORT MODEL(移動のモデルなし)が、そのことを示しています。小さくても、「読む、選ぶ、確定する、終わる」を一通り実行できる出発点です。キーは、1回押すごとに全部離します。教材では、複数のキーの同時押しはすべて受け付けません。元のゲームには、Z+Enter で「読むだけ」になるという優先のルールがあります。それとは区別しています。

次は、第2段階の Event、process、transition のイベント表を読みます。

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

01_read_and_send.py / 注目する部分
    if action.startswith('read:'):
        c = action[5:]
        return replace(s, read=s.read + (c,)) if c in visible(s,l) and c not in s.read else s

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_read_and_send.py
macOS / Linux
.venv/bin/python 01_read_and_send.py
画面で見ること最初に選ばれている song で Z を押すと、READ・TICK0・SENT:NONE になります。Enter で TICK1・SONG SENT:TRUE です。jingle を3回放送すると、TICK4・CLOSED(放送終了)です。
  • 読むだけなら、時計は0のまま
  • song と song_late の記録を分ける
  • 4つの枠を使ったあとの入力は受け付けない
つまずきやすいところ読む操作の分岐にも t+1 を書くと、下見をしただけで枠を使ってしまいます。描画の関数から transition を呼ぶのも、同じ問題を起こします。読んだ印、放送した記録、時計を、それぞれ別々に見て確かめましょう。
少し変えてみよう

INITIAL_CURSOR = 0 を INITIAL_CURSOR = 1 に、1か所だけ変えます。保存して Esc で終了し、同じファイルをもう一度実行します。最初から jingle が選ばれています。Z なら TICK0 のまま、Enter なら TICK1 になり、上キーで song_late を選べます。SONG SENT は FALSE です。最後に0に戻して、保存・終了・もう一度実行します。R は、もう読みこんだコードで最初の状態に戻すだけです。書き換えたファイルは読み直しません。

01_read_and_send.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(316行)
01_read_and_send.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.

"""帰れないラジオ局・教材1/5。登場人物・町・放送はすべて架空です。"""
from dataclasses import dataclass, replace, asdict
import hashlib, json
VERSION = '070-starter'
@dataclass(frozen=True)
class Level:
    id: str
    title: str
    kind: str
LEVELS = (Level('A', '声を読んで放送する', 'A'),)
@dataclass(frozen=True)
class State:
    t: int = 0
    sent: tuple = ()
    read: tuple = ()
    ended: bool = False
@dataclass(frozen=True)
class Timeline:
    state: State
    past: tuple = ()
@dataclass(frozen=True)
class Card:
    id: str
    speaker: str
    title: str
    text: str
    deadline: str
CARDS = {
    'song': Card('song', '演奏者', '最後の歌を紹介', 'この一枠だけ歌の題名を紹介できます。', '枠0だけ'),
    'song_late': Card('song_late', '演奏者', '歌の紹介を次回へ', '今夜は紹介できなかったことを記録します。', '締切後'),
    'jingle': Card('jingle', '放送係', '短い合図', '文字の合図だけ。一枠を進めます。', 'いつでも'),
}
def initial(l):
    return Timeline(State())
def visible(s, l, variant=False):
    song = 'song' if s.t == 0 else 'song_late'
    return (() if 'song' in s.sent or song in s.sent else (song,)) + ('jingle',)
def enabled(s, l, c):
    return not s.ended and c in visible(s, l) and (c == 'jingle' or c not in s.sent)
def transition(s, l, action, variant=False):
    if s.ended:
        return s
    if action.startswith('read:'):
        c = action[5:]
        return replace(s, read=s.read + (c,)) if c in visible(s,l) and c not in s.read else s
    if not action.startswith('send:'):
        return s
    c = action[5:]
    if not enabled(s, l, c):
        return s
    t = s.t + 1
    return replace(s, t=t, sent=s.sent + (c,), ended=t >= 4)
def step(t, l, action, variant=False):
    return Timeline(transition(t.state, l, action, variant))
def canonical(o):
    return json.dumps(o, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
def digest(o):
    return hashlib.sha256(canonical(o).encode('utf-8')).hexdigest()

# 教材用の入力・表示。モデル以外に原作との違いがあります。
STAGE = 1
INITIAL_CURSOR = 0
KEYS = ('UP','DOWN','Z','RETURN','X','R','1','2','3','4','B')
CARD_LABELS = {
 'bus':'BUS: TWO SEATS AT T2', 'bus_late':'BUS: DEPARTED',
 'umbrella':'HELPER: ONE PERSON AT T3', 'umbrella_late':'HELPER: NEXT MORNING',
 'song':'SONG: ONLY SLOT 0', 'song_late':'SONG: MISSED TONIGHT',
 'shelter':'SHELTER: A PLACE TO WAIT', 'thanks':'THANKS: WALK ARRANGED',
 'repair':'LIGHTS: WORK AT T2 (+1)', 'repair_late':'LIGHTS: MORNING WORK',
 'shop':'BOXES: PICKUP AT T2 (+2)', 'shop_late':'BOXES: STORE THEM',
 'quiet':'QUIET: NEXT TICK (-2)', 'lights_call':'CALL: LIGHTS PREPARED',
 'morning_call':'CALL: LIGHTS STILL NEEDED', 'storage_call':'CALL: STORE TWO BOXES',
 'quiet_call':'CALL: EARLY AND NOW', 'ferry':'FERRY: ONE AT T1 AND T4',
 'check':'CHECK: ANSWER IN THREE TICKS', 'bridge':'BRIDGE: CONFIRMED OPEN',
 'bridge_closed':'BRIDGE: CONFIRMED CLOSED', 'jingle':'JINGLE: TEXT SIGNAL / +1 TICK',
 'handoff':'HANDOFF: CLOSE AT CURRENT TICK'}

class App:
    def __init__(self):
        self.level = 0
        self.variant = False
        self.reset()
    @property
    def board(self):
        return LEVELS[self.level]
    @property
    def state(self):
        return self.timeline.state
    def reset(self):
        self.timeline = initial(self.board)
        self.cursor = INITIAL_CURSOR
        self.gated = False
        self.message = 'READING DOES NOT SPEND A SLOT'
    def ids(self):
        return visible(self.state,self.board,self.variant)
    def action(self,action):
        self.timeline = step(self.timeline,self.board,action,self.variant)
        self.cursor = min(self.cursor,len(self.ids())-1)
    def press(self,key):
        if key == 'R':
            self.reset()
            return
        if key in ('1','2','3','4') and int(key) <= len(LEVELS):
            self.level = int(key)-1
            self.reset()
            return
        if key == 'B' and STAGE == 5 and self.board.kind == 'A':
            self.variant = not self.variant
            self.reset()
            return
        if key == 'X' and STAGE == 5:
            self.action('undo')
            return
        if self.state.ended:
            return
        if key in ('UP','DOWN'):
            self.cursor = (self.cursor + (-1 if key=='UP' else 1)) % len(self.ids())
        elif key in ('Z','RETURN'):
            self.action(('read:' if key=='Z' else 'send:') + self.ids()[self.cursor])
    def control(self,pressed,held):
        # 合成テストとPyxelの両方がこの入口を使う。全部離すまで次を受けない。
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.message = 'RELEASE ALL KEYS / CHORD REJECTED'
            self.gated = True
            return
        if len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True
    def notice(self,c):
        s = self.state
        if s.ended:
            return ('CLOSED: PENDING IS NOT ACHIEVED', 'X UNDO (STAGE 5) / R NEW TRIAL')
        if not enabled(s,self.board,c):
            return ('CONDITIONAL SAMPLE ONLY', 'WAIT FOR ITS ACTUAL CALL')
        if c == 'check':
            return ('ANSWER DUE AT T' + str(s.t+3), 'A LATER BROADCAST IS STILL REQUIRED')
        if c == 'bridge':
            return ('CONFIRMED OPEN: HOME NEXT TICK', 'ONLY THIS BROADCAST RESERVES RETURN')
        if c == 'bridge_closed':
            return ('CONFIRMED CLOSED', 'NO BRIDGE RETURN IS RESERVED')
        if c == 'handoff':
            return ('CLOSE NOW / NO CLOCK ADVANCE', 'KEEP PENDING AND UNKNOWN ITEMS')
        if c == 'shelter':
            return ('CONFIRM A PLACE TO WAIT', 'HOME COUNT DOES NOT INCREASE')
        if c == 'ferry':
            return ('ARRIVE NEXT TICK, THEN DEPART', 'BOATS AT T1 / T4, ONE SEAT EACH')
        if c == 'bus':
            return ('ARRIVE NEXT TICK, THEN DEPART', 'BUS AT T2, TWO SEATS')
        if c == 'umbrella':
            return ('ONE WALK HOME AT T3', 'ONLY IF SOMEONE IS STILL WAITING')
        if c == 'quiet':
            return ('AT NEXT TICK: MAX(0, NOISE - 2)', 'AFTER REPAIR AND SHOP AT SAME TICK')
        return ('Z READ / ENTER BROADCAST', 'CHECK DEADLINES BEFORE SENDING')
    def draw(self,p):
        s=self.state
        p.cls(1)
        p.text(14,12,'STRANDED RADIO / STAGE '+str(STAGE)+' / NIGHT '+self.board.id,10)
        p.text(14,27,'FICTIONAL PEOPLE / SILENT TEXT SIGNALS',6)
        p.text(14,47,'TICK '+str(s.t)+' / 4    '+('CLOSED' if s.ended else 'ON AIR')+'    HISTORY '+str(len(self.timeline.past)),11)
        for i in range(4):
            p.rect(440+i*58,46,50,8,11 if i<s.t else 5)
        p.text(14,72,'INCOMING VOICES',10)
        ids=self.ids()
        start=max(0,self.cursor-6)
        for j,c in enumerate(ids[start:start+7]):
            y=88+j*33
            allowed=enabled(s,self.board,c)
            p.rectb(12,y,298,29,10 if start+j==self.cursor else 5)
            p.text(19,y+5,CARD_LABELS[c],7 if allowed else 13)
            label='READ' if c in s.read else 'UNREAD'
            if not allowed and not s.ended:label='CONDITIONAL SAMPLE'
            p.text(19,y+17,label,6)
        p.text(14,326,str(self.cursor+1)+' / '+str(len(ids))+'   B SAMPLES: '+str(self.variant).upper(),6)
        c=ids[min(self.cursor,len(ids)-1)]
        p.text(330,74,'SELECTED: '+c.upper(),10)
        for j,line in enumerate(self.notice(c)):p.text(330,93+j*14,line,7)
        p.text(330,130,'CONFIRMED CURRENT STATE',10)
        p.text(330,149,'SENT: '+(', '.join(s.sent) or 'NONE'),7)
        if STAGE == 1:
            p.text(330,173,'SONG SENT: '+str('song' in s.sent).upper(),11)
            p.text(330,192,'NO TRANSPORT MODEL IN THIS STAGE',6)
        else:
            if self.board.kind in ('A','C'):
                p.text(330,171,'HOME '+str(home_count(s))+' / WAIT '+str(len(s.people)-home_count(s)),11)
                p.text(330,189,'SHELTER CONFIRMED: '+str(s.shelter).upper(),7)
                labels={'P':'SQUARE','H':'HALL','B':'BUS','F':'FERRY','帰宅':'HOME'}
                for j,person in enumerate(s.people):
                    p.text(330+j*86,211,person.id+':'+labels[person.place],7)
                    p.text(330+j*86,224,'AT '+str(person.home_time) if person.place=='帰宅' else 'WAIT',6)
            if self.board.kind=='A':p.text(330,247,'WALK '+str(s.walkers)+' / SONG '+str(s.song).upper(),7)
            if self.board.kind=='B':
                p.text(330,171,'LIGHTS '+str(s.lights).upper()+' / BOXES '+str(s.boxes),11)
                p.text(330,189,'NOISE NOW '+str(s.noise),7)
                p.text(330,211,'EARLY QUIET AT T2: '+('NOT RECORDED' if s.early_quiet<0 else str(bool(s.early_quiet)).upper()),7)
            if self.board.kind=='C':
                p.text(330,247,'BRIDGE: '+{'未確認':'UNKNOWN','開通':'OPEN','閉鎖':'CLOSED'}[s.bridge],11)
                p.text(330,262,'BRIDGE BROADCAST: '+str('bridge' in s.sent).upper(),7)
            p.text(330,285,'PENDING / NOT ACHIEVED',10)
            for j,event in enumerate(s.pending):
                p.text(330,303+j*14,'T'+str(event.due)+' P'+str(event.priority)+' '+event.kind.upper(),7)
            if not s.pending:p.text(330,303,'NONE',6)
        p.line(14,351,702,351,5)
        if s.ended:
            p.text(14,365,'HANDOVER / CURRENT FACTS AND REMAINING WORK',10)
            if STAGE==1:
                p.text(14,384,'SONG: '+('INTRODUCED' if 'song' in s.sent else 'NOT INTRODUCED TONIGHT'),7)
            else:
                for j,line in enumerate(handover_lines(s,self.board)[:5]):p.text(14,384+j*15,line,7)
        else:
            p.text(14,365,'NO CLOCK ADVANCE WHILE READING OR THINKING',10)
            p.text(14,384,'ARRIVALS / WORK -> DEPARTURES / EARLY RECORD -> NEW CALLS -> END',6)
            p.text(14,405,'SLOTS ARE LIMITED. THERE IS NO HIDDEN ALL-HAPPY SCORE.',7)
        p.text(14,469,self.message,10)
        p.text(14,488,'UP/DOWN SELECT / Z READ / ENTER SEND / X UNDO (5) / R RESET',7)
        p.text(14,505,'1-'+str(len(LEVELS))+' NIGHT / B A-SAMPLES (5) / ESC QUIT / RELEASE EACH KEY',6)

def handover_lines(s,l):
    lines=[]
    if l.kind=='A':
        if not s.song:lines.append('SONG: OPPORTUNITY STILL IN SLOT 0' if s.t==0 else 'SONG: MISSED TONIGHT')
        if s.t<2 and 'bus' not in s.sent:lines.append('BUS ANNOUNCEMENT STILL NEEDED BY SLOT 1')
        if s.t<2 and 'umbrella' not in s.sent:lines.append('HELPER REQUEST STILL NEEDED BY SLOT 1')
    if l.kind=='B':
        if not s.lights:lines.append('MORNING LIGHTING WORK REMAINS')
        if s.boxes:lines.append(str(s.boxes)+' BOXES REMAIN FOR STORAGE')
        if s.noise>1 or s.t<2 and 'quiet' not in s.sent:lines.append('QUIET ROUTE / CHECK T2 RECORD')
    if l.kind=='C':
        if 'ferry' not in s.sent and s.t<4:lines.append('FERRY ANNOUNCEMENT STILL NEEDED')
        if s.bridge=='未確認':lines.append('BRIDGE OPEN/CLOSED IS UNKNOWN')
        elif s.bridge=='開通' and 'bridge' not in s.sent:lines.append('BRIDGE CONFIRMED OPEN, NOT BROADCAST')
    if s.people and home_count(s)<len(s.people):lines.append(str(len(s.people)-home_count(s))+' PEOPLE STILL NEED A ROUTE / WAITING PLACE')
    lines.extend('PENDING T'+str(e.due)+': '+e.kind.upper() for e in s.pending)
    return lines or ['NO ADDITIONAL ROUTE TASK / KEEP THE BROADCAST RECORD']

def demo_actions():
    return ('read:song', 'send:song')

def demo_keys():
    app=App()
    keys=[]
    for action in demo_actions():
        if action in ('1','2','3','4','X'):
            keys.append(action);app.press(action);continue
        verb,c=action.split(':',1)
        while app.ids()[app.cursor]!=c:
            keys.append('DOWN');app.press('DOWN')
        key='Z' if verb=='read' else 'RETURN'
        keys.append(key);app.press(key)
    return tuple(keys)

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(720,530,title='Stranded Radio lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App()
    sequence=demo_keys() if demo else ()
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    limit=max(12,2*len(sequence)+6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            index=p.frame_count//2
            pressed={sequence[index]} if demo and p.frame_count%2==0 and index<len(sequence) else set()
            app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    # Pyxelの初期化でcwdが変わる場合にも、出力先は呼出元cwdに固定する。
    if args.screenshot:
        target=Path(args.screenshot).resolve()
        if target.suffix.lower()!='.png' or not target.parent.is_dir() or target.exists():
            parser.error('use a new .png in an existing directory')
        args.screenshot=str(target)
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 02 / 05

予約と到着を、出発より先に処理する

出来事(イベント)の列、到着の順番、条件がそろうと届く電話。

前提は、第2段階と、for、sorted、dataclass にフィールドを追加する方法です。Person には、居場所・到着する時刻・帰宅する時刻を持たせます。Event は、due(実行する時刻)、priority(同じ時刻での順番)、kind(処理の名前)を持ちます。pending は、これから起きる予定の一覧です。予約しただけの人は、HOME(帰宅した)に数えません。夜Aの3人は、作り話の人物A・B・Cです。条件が同じなら、IDの小さい人から選びます。いつも同じ結果になる(決定的な)ルールです。

umbrella を枠0で放送すると時計が1になり、予約には T3 WALK(時計3に歩いて帰る)だけが残ります。続けて bus を枠1で放送すると、時計2の bus_arrive で全員がバス停に着きます。そのあとの出発の処理で、2人が帰宅します。この順番が逆だと、出発と同じ時刻に着いた人が、本当は間に合うのに乗り遅れてしまいます。残った1人は HALL(会館)に移ります。ただし、それだけで「会館を開けた」と放送したことにはなりません。

process は、今の時刻のイベントを pending から取り出し、priority と kind で並べて実行します。そのあとで、乗り物(便)の出発を処理し、新しい電話を追加します。時計2にまだ帰れない人がいると、shelter(避難場所)の電話が届きます。時計3に歩いて帰る案内がうまくいくと、thanks(お礼)の電話が届きます。電話は、出来事の結果から生まれます。shelter の効果は、待つ場所を確かめることだけです。帰宅した人数は増えません。放送したのと同じ時刻に、別の walk の予約も実行されれば、HOME は増えます。でも、その原因を会館の効果とまちがえないようにします。

この段階では、夜Aだけを選べます。元のゲームと同じ State の箱を、この段階から使います。noise・lights・boxes・bridge など、あとの段階で使うフィールドは、最初の値のままにしておきます。夜BとCのカード、状況、イベントの予約は、まだありません。終わり方は、4つの枠を使い切る自然な終わりだけです。早めに放送を終える「早期閉局」と Undo は、第5段階で加えます。

次は、第3段階の process の中にある priority と early_quiet を読みます。

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

02_scheduled_routes.py / 注目する部分
    pending = tuple((e for e in s.pending if e.due != t))
    due = sorted((e for e in s.pending if e.due == t), key=lambda e: (e.priority, e.kind))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_scheduled_routes.py
macOS / Linux
.venv/bin/python 02_scheduled_routes.py
画面で見ること上下キーで umbrella を選んで Enter、bus を選んで Enter と押します。TICK2・HOME2・WAIT1 になり、PENDING T3 WALK が残って、shelter の電話が届きます。jingle を放送すると、TICK3・HOME3・WALK1 になり、thanks の電話が届きます。
  • 予約したことと、実際にできたことを分ける
  • 到着→出発→電話が届く、の順番で処理する
  • 会館は、家に帰る手段ではない
つまずきやすいところshelter の放送と、同じ時刻の walk を、同じ効果だと思わないようにします。bus→jingle→shelter の順に放送すると、歩いて帰る予約がありません。なので HOME は2のままで、SHELTER CONFIRMED だけが TRUE になります。
少し変えてみよう

イベント表の 'umbrella': (3, 0, 'walk') を 'umbrella': (4, 0, 'walk') に変えます。保存して終了し、もう一度実行して、umbrella→bus→jingle と放送します。時計3でも HOME は2で、予約は T4 WALK です。もう1回 jingle を放送すると、時計4で終わる前に歩きの予約が処理され、HOME3 になります。これは模型(ゲームを簡単にした作り)での実験です。カードの説明にある「時計3」は、変える前の説明のままです。実験のあとは必ず3に戻し、保存・終了・もう一度実行して、説明と処理をまた一致させます。R では、コードは読み直されません。

02_scheduled_routes.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(482行)
02_scheduled_routes.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.

"""帰れないラジオ局・教材2/5。登場人物・町・放送はすべて架空です。"""
"""070: finite broadcasts, scheduled effects and honest handover. Pure stdlib."""
from dataclasses import dataclass, replace, asdict
import json, hashlib
VERSION = '070-v1'

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    kind: str
    bridge_open: bool = False
LEVELS = (Level('A', '最後の便と最後の歌', 'A'),)

@dataclass(frozen=True)
class Person:
    id: str
    place: str = 'H'
    arrival: int = 0
    home_time: int = 0

@dataclass(frozen=True)
class Event:
    due: int
    priority: int
    kind: str

@dataclass(frozen=True)
class State:
    t: int = 0
    sent: tuple = ()
    pending: tuple = ()
    people: tuple = ()
    calls: tuple = ()
    read: tuple = ()
    shelter: bool = False
    song: bool = False
    walkers: int = 0
    noise: int = 1
    lights: bool = False
    boxes: int = 2
    early_quiet: int = -1
    bridge: str = '未確認'
    ended: bool = False
    feedback: str = '声を選んで読む。Enterで一枠を確定します。'

@dataclass(frozen=True)
class Timeline:
    state: State
    past: tuple = ()

@dataclass(frozen=True)
class Card:
    id: str
    speaker: str
    title: str
    text: str
    deadline: str
CARDS = {'bus': Card('bus', '運転手', '終便の案内', '公開可: バスは時計2発、座席2。次の時計に停留所へ集まれます。', '枠0/1まで'), 'bus_late': Card('bus_late', '運転手', '終便終了を確認', '公開可: 今夜のバスは出発済み。待つ人は会館で雨宿りできます。', '締切後の案内'), 'umbrella': Card('umbrella', '傘係', '手伝いを一人募集', '公開可: 時計3に一人が迎えに来て、まだ帰れない一人を徒歩で案内します。', '枠0/1まで'), 'umbrella_late': Card('umbrella_late', '傘係', '明朝へ募集を引継ぐ', '公開可: 今夜の募集は終了。明朝の手伝い依頼を引継ぎに残してください。', '締切後の案内'), 'song': Card('song', '演奏者', '最後の歌を紹介', '公開可: 撤収前のこの一枠だけ、今夜の歌の題名を紹介できます。', '枠0だけ'), 'song_late': Card('song_late', '演奏者', '歌の紹介を次回へ', '公開可: 演奏者は撤収済み。今夜は紹介できなかったことを引継げます。', '締切後の案内'), 'shelter': Card('shelter', '会館係', '会館の開放を案内', '公開可: 会館は待てる場所です。帰宅手段が未確認でも、ここで雨宿りできます。', 'いつでも'), 'thanks': Card('thanks', '傘係', '送り先が決まった', '公開可: 徒歩の一人の送り先が決まりました。引き受けた助けを次の係にも伝えて。', '徒歩成立後'), 'jingle': Card('jingle', '放送係', '短い合図と現況', '公開可: 自作の合図と確認済みの現況だけを流します。人の移動は予約しません。', '一枠を進める')}

def initial(l):
    people = tuple((Person(chr(65 + i), 'P' if l.kind == 'A' else 'H') for i in range(3 if l.kind == 'A' else 4 if l.kind == 'C' else 0)))
    return Timeline(State(people=people, calls={'A': ('bus', 'umbrella', 'song')}[l.kind]))

def home_count(s):
    return sum((p.place == '帰宅' for p in s.people))

def early_count(s):
    return sum((p.home_time == 1 for p in s.people))

def enabled(s, l, c):
    if s.ended or c not in CARDS:
        return False
    if c in ('jingle',):
        return True
    if c in s.sent:
        return False
    if c in ('shelter', 'thanks', 'lights_call', 'morning_call', 'storage_call', 'quiet_call'):
        return c in s.calls
    if c.endswith('_late'):
        base = c[:-5]
        return base in s.calls and base not in s.sent and (s.t >= (1 if base == 'song' else 2))
    if c in ('bus', 'umbrella', 'repair', 'shop'):
        return c in s.calls and s.t <= 1
    if c == 'song':
        return c in s.calls and s.t == 0
    return c in s.calls

def visible(s, l, variant=False):
    ids = []
    for c in s.calls:
        if c in s.sent:
            continue
        late = c + '_late'
        if late in CARDS and s.t >= (1 if c == 'song' else 2):
            c = late
        if c not in s.sent:
            ids.append(c)
    return tuple(dict.fromkeys(ids + ['jingle']))

def preview(s, l, c):
    if not enabled(s, l, c):
        return '条件付き見本: ' + ('時計2以後に待つ人がいると着信。' if c == 'shelter' else '時計3に徒歩案内が成立すると着信。')
    if c == 'bus':
        return '時計2の便で二人。案内後の到着を先に処理。'
    if c == 'umbrella':
        return '時計3に、まだ帰れない人がいれば一人を徒歩案内。'
    if c == 'shelter':
        return '待てる場所を確認。帰宅人数は増えません。'
    return CARDS[c].deadline + '。' + ('時計' + str(s.t + 1) + 'へ進みます。')

def pending_text(e):
    return str(e.due) + '刻: ' + {'bus_arrive': '停留所に集合', 'walk': '徒歩の迎え', 'ferry_arrive': '渡し場に集合', 'answer': '橋の公式回答', 'bridge_home': '橋から帰宅'}[e.kind]

def process(s, l, t):
    pending = tuple((e for e in s.pending if e.due != t))
    due = sorted((e for e in s.pending if e.due == t), key=lambda e: (e.priority, e.kind))
    people = list(s.people)
    kw = {'t': t, 'pending': pending}
    noise = s.noise
    lights = s.lights
    boxes = s.boxes
    walkers = s.walkers
    bridge = s.bridge
    for e in due:
        if e.kind in ('bus_arrive', 'ferry_arrive'):
            dest = 'B' if e.kind == 'bus_arrive' else 'F'
            people = [replace(p, place=dest, arrival=t) if p.place != '帰宅' else p for p in people]
        elif e.kind == 'repair':
            lights = True
            noise += 1
        elif e.kind == 'shop':
            boxes = 0
            noise += 2
        elif e.kind == 'quiet':
            noise = max(0, noise - 2)
        elif e.kind == 'walk':
            candidates = [p for p in people if p.place != '帰宅']
            if candidates:
                who = min(candidates, key=lambda p: p.id).id
                people = [replace(p, place='帰宅', home_time=t) if p.id == who else p for p in people]
                walkers += 1
        elif e.kind == 'answer':
            bridge = '開通' if l.bridge_open else '閉鎖'
        elif e.kind == 'bridge_home':
            people = [replace(p, place='帰宅', home_time=t) if p.place != '帰宅' else p for p in people]
    if l.kind == 'A' and t == 2:
        riders = sorted((p for p in people if p.place == 'B'), key=lambda p: (p.arrival, p.id))[:2]
        ids = {p.id for p in riders}
        people = [replace(p, place='帰宅', home_time=t) if p.id in ids else replace(p, place='H') if p.place != '帰宅' else p for p in people]
    s = replace(s, **kw, people=tuple(people), noise=noise, lights=lights, boxes=boxes, walkers=walkers, bridge=bridge)
    add = []
    if l.kind == 'A':
        if t >= 2 and home_count(s) < len(s.people):
            add.append('shelter')
        if walkers:
            add.append('thanks')
    return replace(s, calls=tuple(dict.fromkeys(s.calls + tuple(sorted(add)))))

def transition(s, l, a, variant=False):
    if s.ended:
        return s
    if a.startswith('read:'):
        c = a[5:]
        return replace(s, read=s.read + (c,)) if c in visible(s, l, variant) and c not in s.read else s
    if not a.startswith('send:'):
        return s
    c = a[5:]
    if not enabled(s, l, c):
        return s
    pending = list(s.pending)
    kw = {'sent': s.sent + (c,)}
    event = {'bus': (s.t + 1, 0, 'bus_arrive'), 'umbrella': (3, 0, 'walk')}.get(c)
    if event:
        pending.append(Event(*event))
    if c == 'shelter':
        kw['shelter'] = True
    if c == 'song':
        kw['song'] = True
    s = process(replace(s, pending=tuple(sorted(pending, key=lambda e: (e.due, e.priority, e.kind))), **kw), l, s.t + 1)
    return replace(s, ended=s.t >= 4, feedback='四枠を終了。結果と残ることを引継ぎました。' if s.t >= 4 else CARDS[c].title + 'を確定。次の電話と予定を確認。')

def step(t, l, a, variant=False):
    n = transition(t.state, l, a, variant)
    return Timeline(n)

def result_lines(s, l):
    lines = []
    if s.people:
        lines += ['帰宅 ' + str(home_count(s)) + '人 / 待つ ' + str(len(s.people) - home_count(s)) + '人', '時計1の帰宅 ' + str(early_count(s)) + '人 / 待機場所の確認 ' + ('済' if s.shelter else '未放送')]
    if l.kind == 'A':
        lines += ['徒歩の助け ' + str(s.walkers) + '人 / 歌の紹介 ' + ('できた' if s.song else '後任へ未紹介' if s.t == 0 else '今夜は残せず')]
    lines += ['予約: ' + pending_text(e) for e in s.pending]
    return tuple(lines)

def unresolved(s, l):
    out = []
    if l.kind == 'A':
        if not s.song:
            out.append('歌の紹介は未対応。枠0の機会を後任へ' if s.t == 0 else '今夜の歌は紹介できなかった')
        if s.t < 2 and 'bus' not in s.sent:
            out.append('終便案内が未対応。枠1まで')
        if s.t < 2 and 'umbrella' not in s.sent:
            out.append('傘募集が未対応。枠1まで')
        if home_count(s) < len(s.people):
            out.append(str(len(s.people) - home_count(s)) + '人の帰路と会館待機を引継ぐ')
    out.extend((pending_text(e) for e in s.pending))
    return tuple(out) or ('追加の帰路課題なし。放送記録を後任へ。',)

def canonical(o):
    return json.dumps(o, ensure_ascii=False, sort_keys=True, separators=(',', ':'))

def digest(o):
    return hashlib.sha256(canonical(o).encode('utf-8')).hexdigest()

def replay(l, actions, variant=False):
    if len(actions) > 512:
        raise ValueError('max 512 actions')
    t = initial(l)
    rows = []
    for k, a in enumerate(actions):
        t = step(t, l, a, variant)
        rows.append({'tick': k, 'action': a, 'state': asdict(t.state), 'sha256': digest(asdict(t.state))})
    return {'version': VERSION, 'level': l.id, 'preview_followup': variant, 'events': rows, 'final': asdict(t.state), 'result_lines': result_lines(t.state, l), 'unresolved': unresolved(t.state, l), 'sha256': digest(asdict(t.state))}

# 教材用の入力・表示。モデル以外に原作との違いがあります。
STAGE = 2
INITIAL_CURSOR = 0
KEYS = ('UP','DOWN','Z','RETURN','X','R','1','2','3','4','B')
CARD_LABELS = {
 'bus':'BUS: TWO SEATS AT T2', 'bus_late':'BUS: DEPARTED',
 'umbrella':'HELPER: ONE PERSON AT T3', 'umbrella_late':'HELPER: NEXT MORNING',
 'song':'SONG: ONLY SLOT 0', 'song_late':'SONG: MISSED TONIGHT',
 'shelter':'SHELTER: A PLACE TO WAIT', 'thanks':'THANKS: WALK ARRANGED',
 'repair':'LIGHTS: WORK AT T2 (+1)', 'repair_late':'LIGHTS: MORNING WORK',
 'shop':'BOXES: PICKUP AT T2 (+2)', 'shop_late':'BOXES: STORE THEM',
 'quiet':'QUIET: NEXT TICK (-2)', 'lights_call':'CALL: LIGHTS PREPARED',
 'morning_call':'CALL: LIGHTS STILL NEEDED', 'storage_call':'CALL: STORE TWO BOXES',
 'quiet_call':'CALL: EARLY AND NOW', 'ferry':'FERRY: ONE AT T1 AND T4',
 'check':'CHECK: ANSWER IN THREE TICKS', 'bridge':'BRIDGE: CONFIRMED OPEN',
 'bridge_closed':'BRIDGE: CONFIRMED CLOSED', 'jingle':'JINGLE: TEXT SIGNAL / +1 TICK',
 'handoff':'HANDOFF: CLOSE AT CURRENT TICK'}

class App:
    def __init__(self):
        self.level = 0
        self.variant = False
        self.reset()
    @property
    def board(self):
        return LEVELS[self.level]
    @property
    def state(self):
        return self.timeline.state
    def reset(self):
        self.timeline = initial(self.board)
        self.cursor = INITIAL_CURSOR
        self.gated = False
        self.message = 'READING DOES NOT SPEND A SLOT'
    def ids(self):
        return visible(self.state,self.board,self.variant)
    def action(self,action):
        self.timeline = step(self.timeline,self.board,action,self.variant)
        self.cursor = min(self.cursor,len(self.ids())-1)
    def press(self,key):
        if key == 'R':
            self.reset()
            return
        if key in ('1','2','3','4') and int(key) <= len(LEVELS):
            self.level = int(key)-1
            self.reset()
            return
        if key == 'B' and STAGE == 5 and self.board.kind == 'A':
            self.variant = not self.variant
            self.reset()
            return
        if key == 'X' and STAGE == 5:
            self.action('undo')
            return
        if self.state.ended:
            return
        if key in ('UP','DOWN'):
            self.cursor = (self.cursor + (-1 if key=='UP' else 1)) % len(self.ids())
        elif key in ('Z','RETURN'):
            self.action(('read:' if key=='Z' else 'send:') + self.ids()[self.cursor])
    def control(self,pressed,held):
        # 合成テストとPyxelの両方がこの入口を使う。全部離すまで次を受けない。
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.message = 'RELEASE ALL KEYS / CHORD REJECTED'
            self.gated = True
            return
        if len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True
    def notice(self,c):
        s = self.state
        if s.ended:
            return ('CLOSED: PENDING IS NOT ACHIEVED', 'X UNDO (STAGE 5) / R NEW TRIAL')
        if not enabled(s,self.board,c):
            return ('CONDITIONAL SAMPLE ONLY', 'WAIT FOR ITS ACTUAL CALL')
        if c == 'check':
            return ('ANSWER DUE AT T' + str(s.t+3), 'A LATER BROADCAST IS STILL REQUIRED')
        if c == 'bridge':
            return ('CONFIRMED OPEN: HOME NEXT TICK', 'ONLY THIS BROADCAST RESERVES RETURN')
        if c == 'bridge_closed':
            return ('CONFIRMED CLOSED', 'NO BRIDGE RETURN IS RESERVED')
        if c == 'handoff':
            return ('CLOSE NOW / NO CLOCK ADVANCE', 'KEEP PENDING AND UNKNOWN ITEMS')
        if c == 'shelter':
            return ('CONFIRM A PLACE TO WAIT', 'HOME COUNT DOES NOT INCREASE')
        if c == 'ferry':
            return ('ARRIVE NEXT TICK, THEN DEPART', 'BOATS AT T1 / T4, ONE SEAT EACH')
        if c == 'bus':
            return ('ARRIVE NEXT TICK, THEN DEPART', 'BUS AT T2, TWO SEATS')
        if c == 'umbrella':
            return ('ONE WALK HOME AT T3', 'ONLY IF SOMEONE IS STILL WAITING')
        if c == 'quiet':
            return ('AT NEXT TICK: MAX(0, NOISE - 2)', 'AFTER REPAIR AND SHOP AT SAME TICK')
        return ('Z READ / ENTER BROADCAST', 'CHECK DEADLINES BEFORE SENDING')
    def draw(self,p):
        s=self.state
        p.cls(1)
        p.text(14,12,'STRANDED RADIO / STAGE '+str(STAGE)+' / NIGHT '+self.board.id,10)
        p.text(14,27,'FICTIONAL PEOPLE / SILENT TEXT SIGNALS',6)
        p.text(14,47,'TICK '+str(s.t)+' / 4    '+('CLOSED' if s.ended else 'ON AIR')+'    HISTORY '+str(len(self.timeline.past)),11)
        for i in range(4):
            p.rect(440+i*58,46,50,8,11 if i<s.t else 5)
        p.text(14,72,'INCOMING VOICES',10)
        ids=self.ids()
        start=max(0,self.cursor-6)
        for j,c in enumerate(ids[start:start+7]):
            y=88+j*33
            allowed=enabled(s,self.board,c)
            p.rectb(12,y,298,29,10 if start+j==self.cursor else 5)
            p.text(19,y+5,CARD_LABELS[c],7 if allowed else 13)
            label='READ' if c in s.read else 'UNREAD'
            if not allowed and not s.ended:label='CONDITIONAL SAMPLE'
            p.text(19,y+17,label,6)
        p.text(14,326,str(self.cursor+1)+' / '+str(len(ids))+'   B SAMPLES: '+str(self.variant).upper(),6)
        c=ids[min(self.cursor,len(ids)-1)]
        p.text(330,74,'SELECTED: '+c.upper(),10)
        for j,line in enumerate(self.notice(c)):p.text(330,93+j*14,line,7)
        p.text(330,130,'CONFIRMED CURRENT STATE',10)
        p.text(330,149,'SENT: '+(', '.join(s.sent) or 'NONE'),7)
        if STAGE == 1:
            p.text(330,173,'SONG SENT: '+str('song' in s.sent).upper(),11)
            p.text(330,192,'NO TRANSPORT MODEL IN THIS STAGE',6)
        else:
            if self.board.kind in ('A','C'):
                p.text(330,171,'HOME '+str(home_count(s))+' / WAIT '+str(len(s.people)-home_count(s)),11)
                p.text(330,189,'SHELTER CONFIRMED: '+str(s.shelter).upper(),7)
                labels={'P':'SQUARE','H':'HALL','B':'BUS','F':'FERRY','帰宅':'HOME'}
                for j,person in enumerate(s.people):
                    p.text(330+j*86,211,person.id+':'+labels[person.place],7)
                    p.text(330+j*86,224,'AT '+str(person.home_time) if person.place=='帰宅' else 'WAIT',6)
            if self.board.kind=='A':p.text(330,247,'WALK '+str(s.walkers)+' / SONG '+str(s.song).upper(),7)
            if self.board.kind=='B':
                p.text(330,171,'LIGHTS '+str(s.lights).upper()+' / BOXES '+str(s.boxes),11)
                p.text(330,189,'NOISE NOW '+str(s.noise),7)
                p.text(330,211,'EARLY QUIET AT T2: '+('NOT RECORDED' if s.early_quiet<0 else str(bool(s.early_quiet)).upper()),7)
            if self.board.kind=='C':
                p.text(330,247,'BRIDGE: '+{'未確認':'UNKNOWN','開通':'OPEN','閉鎖':'CLOSED'}[s.bridge],11)
                p.text(330,262,'BRIDGE BROADCAST: '+str('bridge' in s.sent).upper(),7)
            p.text(330,285,'PENDING / NOT ACHIEVED',10)
            for j,event in enumerate(s.pending):
                p.text(330,303+j*14,'T'+str(event.due)+' P'+str(event.priority)+' '+event.kind.upper(),7)
            if not s.pending:p.text(330,303,'NONE',6)
        p.line(14,351,702,351,5)
        if s.ended:
            p.text(14,365,'HANDOVER / CURRENT FACTS AND REMAINING WORK',10)
            if STAGE==1:
                p.text(14,384,'SONG: '+('INTRODUCED' if 'song' in s.sent else 'NOT INTRODUCED TONIGHT'),7)
            else:
                for j,line in enumerate(handover_lines(s,self.board)[:5]):p.text(14,384+j*15,line,7)
        else:
            p.text(14,365,'NO CLOCK ADVANCE WHILE READING OR THINKING',10)
            p.text(14,384,'ARRIVALS / WORK -> DEPARTURES / EARLY RECORD -> NEW CALLS -> END',6)
            p.text(14,405,'SLOTS ARE LIMITED. THERE IS NO HIDDEN ALL-HAPPY SCORE.',7)
        p.text(14,469,self.message,10)
        p.text(14,488,'UP/DOWN SELECT / Z READ / ENTER SEND / X UNDO (5) / R RESET',7)
        p.text(14,505,'1-'+str(len(LEVELS))+' NIGHT / B A-SAMPLES (5) / ESC QUIT / RELEASE EACH KEY',6)

def handover_lines(s,l):
    lines=[]
    if l.kind=='A':
        if not s.song:lines.append('SONG: OPPORTUNITY STILL IN SLOT 0' if s.t==0 else 'SONG: MISSED TONIGHT')
        if s.t<2 and 'bus' not in s.sent:lines.append('BUS ANNOUNCEMENT STILL NEEDED BY SLOT 1')
        if s.t<2 and 'umbrella' not in s.sent:lines.append('HELPER REQUEST STILL NEEDED BY SLOT 1')
    if l.kind=='B':
        if not s.lights:lines.append('MORNING LIGHTING WORK REMAINS')
        if s.boxes:lines.append(str(s.boxes)+' BOXES REMAIN FOR STORAGE')
        if s.noise>1 or s.t<2 and 'quiet' not in s.sent:lines.append('QUIET ROUTE / CHECK T2 RECORD')
    if l.kind=='C':
        if 'ferry' not in s.sent and s.t<4:lines.append('FERRY ANNOUNCEMENT STILL NEEDED')
        if s.bridge=='未確認':lines.append('BRIDGE OPEN/CLOSED IS UNKNOWN')
        elif s.bridge=='開通' and 'bridge' not in s.sent:lines.append('BRIDGE CONFIRMED OPEN, NOT BROADCAST')
    if s.people and home_count(s)<len(s.people):lines.append(str(len(s.people)-home_count(s))+' PEOPLE STILL NEED A ROUTE / WAITING PLACE')
    lines.extend('PENDING T'+str(e.due)+': '+e.kind.upper() for e in s.pending)
    return lines or ['NO ADDITIONAL ROUTE TASK / KEEP THE BROADCAST RECORD']

def demo_actions():
    return ('send:umbrella', 'send:bus')

def demo_keys():
    app=App()
    keys=[]
    for action in demo_actions():
        if action in ('1','2','3','4','X'):
            keys.append(action);app.press(action);continue
        verb,c=action.split(':',1)
        while app.ids()[app.cursor]!=c:
            keys.append('DOWN');app.press('DOWN')
        key='Z' if verb=='read' else 'RETURN'
        keys.append(key);app.press(key)
    return tuple(keys)

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(720,530,title='Stranded Radio lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App()
    sequence=demo_keys() if demo else ()
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    limit=max(12,2*len(sequence)+6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            index=p.frame_count//2
            pressed={sequence[index]} if demo and p.frame_count%2==0 and index<len(sequence) else set()
            app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    # Pyxelの初期化でcwdが変わる場合にも、出力先は呼出元cwdに固定する。
    if args.screenshot:
        target=Path(args.screenshot).resolve()
        if target.suffix.lower()!='.png' or not target.parent.is_dir() or target.exists():
            parser.error('use a new .png in an existing directory')
        args.screenshot=str(target)
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 03 / 05

同じ時計の出来事にも、順序を持たせる

priority(同じ時刻での順番)、下限で止める処理、過去に記録した値。

前提は、第2段階と、負の数、max、比べる式です。2キーで夜Bに切り替えます。最初の騒がしさは1です。repair(修理)は、時計2に灯りを用意して、騒がしさを+1します。shop(店)は、時計2に箱を引き取って+2します。quiet(静かに)は、次の時計で−2します。ただし、騒がしさは0より小さくなりません。なので、順番がちがっても足し算の合計は同じ、とは言えない模型です。途中で max(0,...) を使うからです。

priority は、repair が0、shop が1、quiet が2です。小さい番号から先に処理します。repair→quiet と放送すると、2つの予約が時計2に重なり、騒がしさは1→2→0と変わります。もし quiet を先に処理すれば、1→0→1になります。同じイベントの集まりでも、処理の順番で結果が変わることを、実験で確かめます。kind は、priority まで同じときに、並び順を決めるための補助のキーです。放送した順番だけで、実行する順番を決めることはしません。

時計2の処理が終わったとき、noise<=1 かどうかを、early_quiet に1回だけ記録します。今の noise と過去の記録は、別々の状態です。repair→shop と放送すると、時計2の noise は4で、EARLY QUIET は FALSE です。そのあと quiet で、時計3の noise が2に減っても、時計2の記録は FALSE のままです。quiet を早めに放送すれば、いつもすべての結果がよくなる、という点数のつけ方はありません。灯り、箱、早い静けさを、それぞれ別に残します。

新しい電話も、実際の結果に合わせて届きます。灯りができていれば lights_call、できていなければ morning_call です。まだ引き取っていない箱があると、storage_call も届きます。画面の CONFIRMED CURRENT STATE(確かめた今の状態)と PENDING(予約)を見比べましょう。まだ予約しただけの repair を、「もう用意できた」と読まない練習になります。夜Aの操作も、続けて使えます。

次は、第4段階の enabled、visible、answer の処理を読みます。

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

03_event_priority.py / 注目する部分
        elif e.kind == 'quiet':
            noise = max(0, noise - 2)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_event_priority.py
macOS / Linux
.venv/bin/python 03_event_priority.py
画面で見ること2を押してから、repair を放送し、quiet を放送します。時計2で、LIGHTS TRUE・NOISE NOW0・EARLY QUIET TRUE になります。R→shop→quiet なら、BOXES0・NOISE NOW1 です。
  • 優先順位は、小さい番号から処理する
  • 0で止める処理が、どこで行われるかを見る
  • 時計2の記録を、今の値と分ける
つまずきやすいところearly_quiet を毎回の時計で noise<=1 に上書きすると、あとから静かになったことで、早い時刻の記録が書き換わってしまいます。また、noise がマイナスになるのを許すと、イベントの順番の意味が変わってしまいます。
少し変えてみよう

イベント表の 'quiet': (s.t + 1, 2, 'quiet') だけを、'quiet': (s.t + 1, -1, 'quiet') に変えます。保存して終了し、もう一度実行します。2→repair→quiet で、時計2の noise は1になります。元の0とのちがいを確かめましょう。これは、優先順位をわざとこわして比べる実験です。なので、画面の AFTER REPAIR AND SHOP は、元のルールでの説明のままです。確かめたら2に戻して、保存・終了・もう一度実行します。R はコードを読み直しません。

03_event_priority.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(499行)
03_event_priority.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.

"""帰れないラジオ局・教材3/5。登場人物・町・放送はすべて架空です。"""
"""070: finite broadcasts, scheduled effects and honest handover. Pure stdlib."""
from dataclasses import dataclass, replace, asdict
import json, hashlib
VERSION = '070-v1'

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    kind: str
    bridge_open: bool = False
LEVELS = (Level('A', '最後の便と最後の歌', 'A'), Level('B', '明日の市と眠る通り', 'B'))

@dataclass(frozen=True)
class Person:
    id: str
    place: str = 'H'
    arrival: int = 0
    home_time: int = 0

@dataclass(frozen=True)
class Event:
    due: int
    priority: int
    kind: str

@dataclass(frozen=True)
class State:
    t: int = 0
    sent: tuple = ()
    pending: tuple = ()
    people: tuple = ()
    calls: tuple = ()
    read: tuple = ()
    shelter: bool = False
    song: bool = False
    walkers: int = 0
    noise: int = 1
    lights: bool = False
    boxes: int = 2
    early_quiet: int = -1
    bridge: str = '未確認'
    ended: bool = False
    feedback: str = '声を選んで読む。Enterで一枠を確定します。'

@dataclass(frozen=True)
class Timeline:
    state: State
    past: tuple = ()

@dataclass(frozen=True)
class Card:
    id: str
    speaker: str
    title: str
    text: str
    deadline: str
CARDS = {'bus': Card('bus', '運転手', '終便の案内', '公開可: バスは時計2発、座席2。次の時計に停留所へ集まれます。', '枠0/1まで'), 'bus_late': Card('bus_late', '運転手', '終便終了を確認', '公開可: 今夜のバスは出発済み。待つ人は会館で雨宿りできます。', '締切後の案内'), 'umbrella': Card('umbrella', '傘係', '手伝いを一人募集', '公開可: 時計3に一人が迎えに来て、まだ帰れない一人を徒歩で案内します。', '枠0/1まで'), 'umbrella_late': Card('umbrella_late', '傘係', '明朝へ募集を引継ぐ', '公開可: 今夜の募集は終了。明朝の手伝い依頼を引継ぎに残してください。', '締切後の案内'), 'song': Card('song', '演奏者', '最後の歌を紹介', '公開可: 撤収前のこの一枠だけ、今夜の歌の題名を紹介できます。', '枠0だけ'), 'song_late': Card('song_late', '演奏者', '歌の紹介を次回へ', '公開可: 演奏者は撤収済み。今夜は紹介できなかったことを引継げます。', '締切後の案内'), 'shelter': Card('shelter', '会館係', '会館の開放を案内', '公開可: 会館は待てる場所です。帰宅手段が未確認でも、ここで雨宿りできます。', 'いつでも'), 'thanks': Card('thanks', '傘係', '送り先が決まった', '公開可: 徒歩の一人の送り先が決まりました。引き受けた助けを次の係にも伝えて。', '徒歩成立後'), 'repair': Card('repair', '修理係', '明日の灯りに手伝い', '公開可: 時計2に二人が集まり、灯りを準備。作業で騒がしさが1増えます。', '枠0/1まで'), 'repair_late': Card('repair_late', '修理係', '朝の手作業へ引継ぐ', '公開可: 今夜の準備募集は終了。朝の手作業を次の係へ残してください。', '締切後の案内'), 'shop': Card('shop', '店主', '閉店前の引取り', '公開可: 時計2に二箱を引き取ります。客の移動で騒がしさが2増えます。', '枠0/1まで'), 'shop_late': Card('shop_late', '店主', '箱の保管を案内', '公開可: 引取り時間は終了。残る箱は店内で保管できます。', '締切後の案内'), 'quiet': Card('quiet', '住民', '静かな帰路の案内', '公開可: 次の時計に騒がしさを2減らします。静けさは0が下限です。', '早寝の記録は時計2'), 'lights_call': Card('lights_call', '翌朝担当', '灯りの準備を確認', '公開可: 明日の灯りは準備済み。引き受けた二人の助けを記録してください。', '準備成立後'), 'morning_call': Card('morning_call', '翌朝担当', '朝の準備が残る', '公開可: 灯りは未準備です。朝に手作業が残ることを引継いでください。', '時計2以後'), 'storage_call': Card('storage_call', '店主', '保管場所の確認', '公開可: 残った二箱は店内で保管します。未引取りの数を残してください。', '時計2以後'), 'quiet_call': Card('quiet_call', '住民', '通りの記録を確認', '公開可: 時計2に記録した静けさと、現在の状態を引継いでください。', '時計2以後'), 'jingle': Card('jingle', '放送係', '短い合図と現況', '公開可: 自作の合図と確認済みの現況だけを流します。人の移動は予約しません。', '一枠を進める')}

def initial(l):
    people = tuple((Person(chr(65 + i), 'P' if l.kind == 'A' else 'H') for i in range(3 if l.kind == 'A' else 4 if l.kind == 'C' else 0)))
    return Timeline(State(people=people, calls={'A': ('bus', 'umbrella', 'song'), 'B': ('repair', 'shop', 'quiet')}[l.kind]))

def home_count(s):
    return sum((p.place == '帰宅' for p in s.people))

def early_count(s):
    return sum((p.home_time == 1 for p in s.people))

def enabled(s, l, c):
    if s.ended or c not in CARDS:
        return False
    if c in ('jingle',):
        return True
    if c in s.sent:
        return False
    if c in ('shelter', 'thanks', 'lights_call', 'morning_call', 'storage_call', 'quiet_call'):
        return c in s.calls
    if c.endswith('_late'):
        base = c[:-5]
        return base in s.calls and base not in s.sent and (s.t >= (1 if base == 'song' else 2))
    if c in ('bus', 'umbrella', 'repair', 'shop'):
        return c in s.calls and s.t <= 1
    if c == 'song':
        return c in s.calls and s.t == 0
    return c in s.calls

def visible(s, l, variant=False):
    ids = []
    for c in s.calls:
        if c in s.sent:
            continue
        late = c + '_late'
        if late in CARDS and s.t >= (1 if c == 'song' else 2):
            c = late
        if c not in s.sent:
            ids.append(c)
    return tuple(dict.fromkeys(ids + ['jingle']))

def preview(s, l, c):
    if not enabled(s, l, c):
        return '条件付き見本: ' + ('時計2以後に待つ人がいると着信。' if c == 'shelter' else '時計3に徒歩案内が成立すると着信。')
    if c == 'bus':
        return '時計2の便で二人。案内後の到着を先に処理。'
    if c == 'umbrella':
        return '時計3に、まだ帰れない人がいれば一人を徒歩案内。'
    if c == 'quiet':
        return '次の時計に騒がしさを2減らす。同刻の作業と客の後。'
    if c == 'shelter':
        return '待てる場所を確認。帰宅人数は増えません。'
    return CARDS[c].deadline + '。' + ('時計' + str(s.t + 1) + 'へ進みます。')

def pending_text(e):
    return str(e.due) + '刻: ' + {'bus_arrive': '停留所に集合', 'walk': '徒歩の迎え', 'repair': '灯りの作業', 'shop': '箱の引取り', 'quiet': '静かな帰路', 'ferry_arrive': '渡し場に集合', 'answer': '橋の公式回答', 'bridge_home': '橋から帰宅'}[e.kind]

def process(s, l, t):
    pending = tuple((e for e in s.pending if e.due != t))
    due = sorted((e for e in s.pending if e.due == t), key=lambda e: (e.priority, e.kind))
    people = list(s.people)
    kw = {'t': t, 'pending': pending}
    noise = s.noise
    lights = s.lights
    boxes = s.boxes
    walkers = s.walkers
    bridge = s.bridge
    for e in due:
        if e.kind in ('bus_arrive', 'ferry_arrive'):
            dest = 'B' if e.kind == 'bus_arrive' else 'F'
            people = [replace(p, place=dest, arrival=t) if p.place != '帰宅' else p for p in people]
        elif e.kind == 'repair':
            lights = True
            noise += 1
        elif e.kind == 'shop':
            boxes = 0
            noise += 2
        elif e.kind == 'quiet':
            noise = max(0, noise - 2)
        elif e.kind == 'walk':
            candidates = [p for p in people if p.place != '帰宅']
            if candidates:
                who = min(candidates, key=lambda p: p.id).id
                people = [replace(p, place='帰宅', home_time=t) if p.id == who else p for p in people]
                walkers += 1
        elif e.kind == 'answer':
            bridge = '開通' if l.bridge_open else '閉鎖'
        elif e.kind == 'bridge_home':
            people = [replace(p, place='帰宅', home_time=t) if p.place != '帰宅' else p for p in people]
    if l.kind == 'A' and t == 2:
        riders = sorted((p for p in people if p.place == 'B'), key=lambda p: (p.arrival, p.id))[:2]
        ids = {p.id for p in riders}
        people = [replace(p, place='帰宅', home_time=t) if p.id in ids else replace(p, place='H') if p.place != '帰宅' else p for p in people]
    if l.kind == 'B' and t == 2:
        kw['early_quiet'] = int(noise <= 1)
    s = replace(s, **kw, people=tuple(people), noise=noise, lights=lights, boxes=boxes, walkers=walkers, bridge=bridge)
    add = []
    if l.kind == 'A':
        if t >= 2 and home_count(s) < len(s.people):
            add.append('shelter')
        if walkers:
            add.append('thanks')
    if l.kind == 'B' and t >= 2:
        add.extend(['lights_call' if lights else 'morning_call', 'quiet_call'])
        if boxes:
            add.append('storage_call')
    return replace(s, calls=tuple(dict.fromkeys(s.calls + tuple(sorted(add)))))

def transition(s, l, a, variant=False):
    if s.ended:
        return s
    if a.startswith('read:'):
        c = a[5:]
        return replace(s, read=s.read + (c,)) if c in visible(s, l, variant) and c not in s.read else s
    if not a.startswith('send:'):
        return s
    c = a[5:]
    if not enabled(s, l, c):
        return s
    pending = list(s.pending)
    kw = {'sent': s.sent + (c,)}
    event = {'bus': (s.t + 1, 0, 'bus_arrive'), 'umbrella': (3, 0, 'walk'), 'repair': (2, 0, 'repair'), 'shop': (2, 1, 'shop'), 'quiet': (s.t + 1, 2, 'quiet')}.get(c)
    if event:
        pending.append(Event(*event))
    if c == 'shelter':
        kw['shelter'] = True
    if c == 'song':
        kw['song'] = True
    s = process(replace(s, pending=tuple(sorted(pending, key=lambda e: (e.due, e.priority, e.kind))), **kw), l, s.t + 1)
    return replace(s, ended=s.t >= 4, feedback='四枠を終了。結果と残ることを引継ぎました。' if s.t >= 4 else CARDS[c].title + 'を確定。次の電話と予定を確認。')

def step(t, l, a, variant=False):
    n = transition(t.state, l, a, variant)
    return Timeline(n)

def result_lines(s, l):
    lines = []
    if s.people:
        lines += ['帰宅 ' + str(home_count(s)) + '人 / 待つ ' + str(len(s.people) - home_count(s)) + '人', '時計1の帰宅 ' + str(early_count(s)) + '人 / 待機場所の確認 ' + ('済' if s.shelter else '未放送')]
    if l.kind == 'A':
        lines += ['徒歩の助け ' + str(s.walkers) + '人 / 歌の紹介 ' + ('できた' if s.song else '後任へ未紹介' if s.t == 0 else '今夜は残せず')]
    if l.kind == 'B':
        lines += ['灯り ' + ('準備済' if s.lights else '朝の手作業が残る') + ' / 箱 ' + str(s.boxes) + '箱', '騒がしさ ' + str(s.noise) + ' / ' + ('早い静けさ' if s.early_quiet == 1 else '時計2は未記録' if s.early_quiet < 0 else '遅くなったが落ち着いた' if s.noise <= 1 else 'まだ静かな帰路が必要')]
    lines += ['予約: ' + pending_text(e) for e in s.pending]
    return tuple(lines)

def unresolved(s, l):
    out = []
    if l.kind == 'A':
        if not s.song:
            out.append('歌の紹介は未対応。枠0の機会を後任へ' if s.t == 0 else '今夜の歌は紹介できなかった')
        if s.t < 2 and 'bus' not in s.sent:
            out.append('終便案内が未対応。枠1まで')
        if s.t < 2 and 'umbrella' not in s.sent:
            out.append('傘募集が未対応。枠1まで')
        if home_count(s) < len(s.people):
            out.append(str(len(s.people) - home_count(s)) + '人の帰路と会館待機を引継ぐ')
    if l.kind == 'B':
        if not s.lights:
            out.append('明朝の灯り準備')
        if s.boxes:
            out.append(str(s.boxes) + '箱を店内保管')
        if s.noise > 1 or (s.t < 2 and 'quiet' not in s.sent):
            out.append('静かな帰路の案内。時計2の記録を確認')
    out.extend((pending_text(e) for e in s.pending))
    return tuple(out) or ('追加の帰路課題なし。放送記録を後任へ。',)

def canonical(o):
    return json.dumps(o, ensure_ascii=False, sort_keys=True, separators=(',', ':'))

def digest(o):
    return hashlib.sha256(canonical(o).encode('utf-8')).hexdigest()

def replay(l, actions, variant=False):
    if len(actions) > 512:
        raise ValueError('max 512 actions')
    t = initial(l)
    rows = []
    for k, a in enumerate(actions):
        t = step(t, l, a, variant)
        rows.append({'tick': k, 'action': a, 'state': asdict(t.state), 'sha256': digest(asdict(t.state))})
    return {'version': VERSION, 'level': l.id, 'preview_followup': variant, 'events': rows, 'final': asdict(t.state), 'result_lines': result_lines(t.state, l), 'unresolved': unresolved(t.state, l), 'sha256': digest(asdict(t.state))}

# 教材用の入力・表示。モデル以外に原作との違いがあります。
STAGE = 3
INITIAL_CURSOR = 0
KEYS = ('UP','DOWN','Z','RETURN','X','R','1','2','3','4','B')
CARD_LABELS = {
 'bus':'BUS: TWO SEATS AT T2', 'bus_late':'BUS: DEPARTED',
 'umbrella':'HELPER: ONE PERSON AT T3', 'umbrella_late':'HELPER: NEXT MORNING',
 'song':'SONG: ONLY SLOT 0', 'song_late':'SONG: MISSED TONIGHT',
 'shelter':'SHELTER: A PLACE TO WAIT', 'thanks':'THANKS: WALK ARRANGED',
 'repair':'LIGHTS: WORK AT T2 (+1)', 'repair_late':'LIGHTS: MORNING WORK',
 'shop':'BOXES: PICKUP AT T2 (+2)', 'shop_late':'BOXES: STORE THEM',
 'quiet':'QUIET: NEXT TICK (-2)', 'lights_call':'CALL: LIGHTS PREPARED',
 'morning_call':'CALL: LIGHTS STILL NEEDED', 'storage_call':'CALL: STORE TWO BOXES',
 'quiet_call':'CALL: EARLY AND NOW', 'ferry':'FERRY: ONE AT T1 AND T4',
 'check':'CHECK: ANSWER IN THREE TICKS', 'bridge':'BRIDGE: CONFIRMED OPEN',
 'bridge_closed':'BRIDGE: CONFIRMED CLOSED', 'jingle':'JINGLE: TEXT SIGNAL / +1 TICK',
 'handoff':'HANDOFF: CLOSE AT CURRENT TICK'}

class App:
    def __init__(self):
        self.level = 0
        self.variant = False
        self.reset()
    @property
    def board(self):
        return LEVELS[self.level]
    @property
    def state(self):
        return self.timeline.state
    def reset(self):
        self.timeline = initial(self.board)
        self.cursor = INITIAL_CURSOR
        self.gated = False
        self.message = 'READING DOES NOT SPEND A SLOT'
    def ids(self):
        return visible(self.state,self.board,self.variant)
    def action(self,action):
        self.timeline = step(self.timeline,self.board,action,self.variant)
        self.cursor = min(self.cursor,len(self.ids())-1)
    def press(self,key):
        if key == 'R':
            self.reset()
            return
        if key in ('1','2','3','4') and int(key) <= len(LEVELS):
            self.level = int(key)-1
            self.reset()
            return
        if key == 'B' and STAGE == 5 and self.board.kind == 'A':
            self.variant = not self.variant
            self.reset()
            return
        if key == 'X' and STAGE == 5:
            self.action('undo')
            return
        if self.state.ended:
            return
        if key in ('UP','DOWN'):
            self.cursor = (self.cursor + (-1 if key=='UP' else 1)) % len(self.ids())
        elif key in ('Z','RETURN'):
            self.action(('read:' if key=='Z' else 'send:') + self.ids()[self.cursor])
    def control(self,pressed,held):
        # 合成テストとPyxelの両方がこの入口を使う。全部離すまで次を受けない。
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.message = 'RELEASE ALL KEYS / CHORD REJECTED'
            self.gated = True
            return
        if len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True
    def notice(self,c):
        s = self.state
        if s.ended:
            return ('CLOSED: PENDING IS NOT ACHIEVED', 'X UNDO (STAGE 5) / R NEW TRIAL')
        if not enabled(s,self.board,c):
            return ('CONDITIONAL SAMPLE ONLY', 'WAIT FOR ITS ACTUAL CALL')
        if c == 'check':
            return ('ANSWER DUE AT T' + str(s.t+3), 'A LATER BROADCAST IS STILL REQUIRED')
        if c == 'bridge':
            return ('CONFIRMED OPEN: HOME NEXT TICK', 'ONLY THIS BROADCAST RESERVES RETURN')
        if c == 'bridge_closed':
            return ('CONFIRMED CLOSED', 'NO BRIDGE RETURN IS RESERVED')
        if c == 'handoff':
            return ('CLOSE NOW / NO CLOCK ADVANCE', 'KEEP PENDING AND UNKNOWN ITEMS')
        if c == 'shelter':
            return ('CONFIRM A PLACE TO WAIT', 'HOME COUNT DOES NOT INCREASE')
        if c == 'ferry':
            return ('ARRIVE NEXT TICK, THEN DEPART', 'BOATS AT T1 / T4, ONE SEAT EACH')
        if c == 'bus':
            return ('ARRIVE NEXT TICK, THEN DEPART', 'BUS AT T2, TWO SEATS')
        if c == 'umbrella':
            return ('ONE WALK HOME AT T3', 'ONLY IF SOMEONE IS STILL WAITING')
        if c == 'quiet':
            return ('AT NEXT TICK: MAX(0, NOISE - 2)', 'AFTER REPAIR AND SHOP AT SAME TICK')
        return ('Z READ / ENTER BROADCAST', 'CHECK DEADLINES BEFORE SENDING')
    def draw(self,p):
        s=self.state
        p.cls(1)
        p.text(14,12,'STRANDED RADIO / STAGE '+str(STAGE)+' / NIGHT '+self.board.id,10)
        p.text(14,27,'FICTIONAL PEOPLE / SILENT TEXT SIGNALS',6)
        p.text(14,47,'TICK '+str(s.t)+' / 4    '+('CLOSED' if s.ended else 'ON AIR')+'    HISTORY '+str(len(self.timeline.past)),11)
        for i in range(4):
            p.rect(440+i*58,46,50,8,11 if i<s.t else 5)
        p.text(14,72,'INCOMING VOICES',10)
        ids=self.ids()
        start=max(0,self.cursor-6)
        for j,c in enumerate(ids[start:start+7]):
            y=88+j*33
            allowed=enabled(s,self.board,c)
            p.rectb(12,y,298,29,10 if start+j==self.cursor else 5)
            p.text(19,y+5,CARD_LABELS[c],7 if allowed else 13)
            label='READ' if c in s.read else 'UNREAD'
            if not allowed and not s.ended:label='CONDITIONAL SAMPLE'
            p.text(19,y+17,label,6)
        p.text(14,326,str(self.cursor+1)+' / '+str(len(ids))+'   B SAMPLES: '+str(self.variant).upper(),6)
        c=ids[min(self.cursor,len(ids)-1)]
        p.text(330,74,'SELECTED: '+c.upper(),10)
        for j,line in enumerate(self.notice(c)):p.text(330,93+j*14,line,7)
        p.text(330,130,'CONFIRMED CURRENT STATE',10)
        p.text(330,149,'SENT: '+(', '.join(s.sent) or 'NONE'),7)
        if STAGE == 1:
            p.text(330,173,'SONG SENT: '+str('song' in s.sent).upper(),11)
            p.text(330,192,'NO TRANSPORT MODEL IN THIS STAGE',6)
        else:
            if self.board.kind in ('A','C'):
                p.text(330,171,'HOME '+str(home_count(s))+' / WAIT '+str(len(s.people)-home_count(s)),11)
                p.text(330,189,'SHELTER CONFIRMED: '+str(s.shelter).upper(),7)
                labels={'P':'SQUARE','H':'HALL','B':'BUS','F':'FERRY','帰宅':'HOME'}
                for j,person in enumerate(s.people):
                    p.text(330+j*86,211,person.id+':'+labels[person.place],7)
                    p.text(330+j*86,224,'AT '+str(person.home_time) if person.place=='帰宅' else 'WAIT',6)
            if self.board.kind=='A':p.text(330,247,'WALK '+str(s.walkers)+' / SONG '+str(s.song).upper(),7)
            if self.board.kind=='B':
                p.text(330,171,'LIGHTS '+str(s.lights).upper()+' / BOXES '+str(s.boxes),11)
                p.text(330,189,'NOISE NOW '+str(s.noise),7)
                p.text(330,211,'EARLY QUIET AT T2: '+('NOT RECORDED' if s.early_quiet<0 else str(bool(s.early_quiet)).upper()),7)
            if self.board.kind=='C':
                p.text(330,247,'BRIDGE: '+{'未確認':'UNKNOWN','開通':'OPEN','閉鎖':'CLOSED'}[s.bridge],11)
                p.text(330,262,'BRIDGE BROADCAST: '+str('bridge' in s.sent).upper(),7)
            p.text(330,285,'PENDING / NOT ACHIEVED',10)
            for j,event in enumerate(s.pending):
                p.text(330,303+j*14,'T'+str(event.due)+' P'+str(event.priority)+' '+event.kind.upper(),7)
            if not s.pending:p.text(330,303,'NONE',6)
        p.line(14,351,702,351,5)
        if s.ended:
            p.text(14,365,'HANDOVER / CURRENT FACTS AND REMAINING WORK',10)
            if STAGE==1:
                p.text(14,384,'SONG: '+('INTRODUCED' if 'song' in s.sent else 'NOT INTRODUCED TONIGHT'),7)
            else:
                for j,line in enumerate(handover_lines(s,self.board)[:5]):p.text(14,384+j*15,line,7)
        else:
            p.text(14,365,'NO CLOCK ADVANCE WHILE READING OR THINKING',10)
            p.text(14,384,'ARRIVALS / WORK -> DEPARTURES / EARLY RECORD -> NEW CALLS -> END',6)
            p.text(14,405,'SLOTS ARE LIMITED. THERE IS NO HIDDEN ALL-HAPPY SCORE.',7)
        p.text(14,469,self.message,10)
        p.text(14,488,'UP/DOWN SELECT / Z READ / ENTER SEND / X UNDO (5) / R RESET',7)
        p.text(14,505,'1-'+str(len(LEVELS))+' NIGHT / B A-SAMPLES (5) / ESC QUIT / RELEASE EACH KEY',6)

def handover_lines(s,l):
    lines=[]
    if l.kind=='A':
        if not s.song:lines.append('SONG: OPPORTUNITY STILL IN SLOT 0' if s.t==0 else 'SONG: MISSED TONIGHT')
        if s.t<2 and 'bus' not in s.sent:lines.append('BUS ANNOUNCEMENT STILL NEEDED BY SLOT 1')
        if s.t<2 and 'umbrella' not in s.sent:lines.append('HELPER REQUEST STILL NEEDED BY SLOT 1')
    if l.kind=='B':
        if not s.lights:lines.append('MORNING LIGHTING WORK REMAINS')
        if s.boxes:lines.append(str(s.boxes)+' BOXES REMAIN FOR STORAGE')
        if s.noise>1 or s.t<2 and 'quiet' not in s.sent:lines.append('QUIET ROUTE / CHECK T2 RECORD')
    if l.kind=='C':
        if 'ferry' not in s.sent and s.t<4:lines.append('FERRY ANNOUNCEMENT STILL NEEDED')
        if s.bridge=='未確認':lines.append('BRIDGE OPEN/CLOSED IS UNKNOWN')
        elif s.bridge=='開通' and 'bridge' not in s.sent:lines.append('BRIDGE CONFIRMED OPEN, NOT BROADCAST')
    if s.people and home_count(s)<len(s.people):lines.append(str(len(s.people)-home_count(s))+' PEOPLE STILL NEED A ROUTE / WAITING PLACE')
    lines.extend('PENDING T'+str(e.due)+': '+e.kind.upper() for e in s.pending)
    return lines or ['NO ADDITIONAL ROUTE TASK / KEEP THE BROADCAST RECORD']

def demo_actions():
    return ('2', 'send:repair', 'send:quiet')

def demo_keys():
    app=App()
    keys=[]
    for action in demo_actions():
        if action in ('1','2','3','4','X'):
            keys.append(action);app.press(action);continue
        verb,c=action.split(':',1)
        while app.ids()[app.cursor]!=c:
            keys.append('DOWN');app.press('DOWN')
        key='Z' if verb=='read' else 'RETURN'
        keys.append(key);app.press(key)
    return tuple(keys)

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(720,530,title='Stranded Radio lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App()
    sequence=demo_keys() if demo else ()
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    limit=max(12,2*len(sequence)+6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            index=p.frame_count//2
            pressed={sequence[index]} if demo and p.frame_count%2==0 and index<len(sequence) else set()
            app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    # Pyxelの初期化でcwdが変わる場合にも、出力先は呼出元cwdに固定する。
    if args.screenshot:
        target=Path(args.screenshot).resolve()
        if target.suffix.lower()!='.png' or not target.parent.is_dir() or target.exists():
            parser.error('use a new .png in an existing directory')
        args.screenshot=str(target)
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 04 / 05

確認が届くまで、橋を未確認のままにする

情報を見せてよい境目、遅れて届く答え、判断のためにもう1枠必要なこと。

前提は、第3段階と、状態の条件によってカードを見せる仕組みです。3キーで夜C1、4キーで夜C2になります。どちらも、確かめるために固定した別々の夜です。コードの Level.bridge_open には、橋が通れるかどうかの本当の答え(真相)が入っています。しかし、プレイヤーに見せるのは State.bridge だけです。最初の値は、どちらの夜も「未確認」です。画面、notice、preview は、答えが届く前に、設定された真相を読みません。確率で橋が開いたり閉じたりするゲームではありません。R でも、同じ夜を最初から始めます。

check(橋の確認)を時計0で放送すると、answer(答え)は時計3に届きます。今の枠が終わってすぐに、答えが来るわけではありません。check→ferry(船)→jingle と放送すると、時計3で答えが届きます。橋が通れる夜は bridge、通れない夜は bridge_closed が、着信の欄に出てきます。通れると確かめただけでは、だれも帰宅しません。残った枠で bridge を放送して、はじめて次の時計に bridge_home(橋を渡って帰る)が予約されます。そして、時計4で帰宅します。

船は、時計1と時計4に1人ずつ出ます。先に ferry を放送すれば、最初の1人を時計1で帰せます。そのあとで check を放送すると、答えは時計4に届きます。これは、4つの枠が終わるのと同じ時刻です。なので、通れるという答えが来ても、そのあとで bridge を放送できません。2人が帰宅し、そのうち1人は早く帰れました。その一方で、橋の案内は次の担当へ残ります。check を先にする順番では、最初の船を逃します。どの結果を大事にするかは、別の話です。かくれた合計点は作りません。

もっと遅く、時計2で check を放送すると、答えは時計5に届きます。4つの枠が終わっても橋は未確認のままで、PENDING T5 ANSWER が残ります。描画のたびに未来を先に計算して、真相を先に見せてはいけません。元のゲームの preview は、予約の時刻と、あと何枠必要かを説明する文章です。未来の決まった結果ではありません。この段階には、早期閉局と Undo はまだありません。

次は、第5段階の transition の handoff の分岐と、step の past を読みます。

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

04_verified_bridge.py / 注目する部分
        elif e.kind == 'answer':
            bridge = '開通' if l.bridge_open else '閉鎖'

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_verified_bridge.py
macOS / Linux
.venv/bin/python 04_verified_bridge.py
画面で見ること4→check→ferry→jingle と進めると、TICK3・BRIDGE OPEN・HOME0・BRIDGE BROADCAST FALSE です。bridge を放送すると、TICK4・HOME4 になります。3を押して同じ順に進めると CLOSED になり、bridge_closed を放送したあとは HOME1 です。
  • 設定された真相を、答えが届く前に表示しない
  • 確かめたあとにも、放送の枠が1つ必要
  • 4つの枠が終わったあとの予約は、まだできていないものとして扱う
つまずきやすいところcheck の直後に Level.bridge_open を表示すると、確かめに行く前に答えがもれてしまいます。answer の処理で bridge_home まで予約してしまうと、「確かめる」と「放送する」という別々の判断がなくなってしまいます。
少し変えてみよう

LEVELS の C2 だけにある 'C', True) を 'C', False) に変えます。保存して終了し、もう一度実行して、4→check→ferry と進めます。時計2までは、変える前と同じ UNKNOWN(未確認)です。jingle で時計3に進むと CLOSED になり、bridge ではなく bridge_closed が出てきます。橋の答えと帰宅が別のことだと確かめたら、True に戻して、保存・終了・もう一度実行します。R はコードを読み直しません。

04_verified_bridge.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(528行)
04_verified_bridge.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.

"""帰れないラジオ局・教材4/5。登場人物・町・放送はすべて架空です。"""
"""070: finite broadcasts, scheduled effects and honest handover. Pure stdlib."""
from dataclasses import dataclass, replace, asdict
import json, hashlib
VERSION = '070-v1'

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    kind: str
    bridge_open: bool = False
LEVELS = (Level('A', '最後の便と最後の歌', 'A'), Level('B', '明日の市と眠る通り', 'B'), Level('C1', 'まだ確かめていない橋', 'C'), Level('C2', '橋の確認用の別夜', 'C', True))

@dataclass(frozen=True)
class Person:
    id: str
    place: str = 'H'
    arrival: int = 0
    home_time: int = 0

@dataclass(frozen=True)
class Event:
    due: int
    priority: int
    kind: str

@dataclass(frozen=True)
class State:
    t: int = 0
    sent: tuple = ()
    pending: tuple = ()
    people: tuple = ()
    calls: tuple = ()
    read: tuple = ()
    shelter: bool = False
    song: bool = False
    walkers: int = 0
    noise: int = 1
    lights: bool = False
    boxes: int = 2
    early_quiet: int = -1
    bridge: str = '未確認'
    ended: bool = False
    feedback: str = '声を選んで読む。Enterで一枠を確定します。'

@dataclass(frozen=True)
class Timeline:
    state: State
    past: tuple = ()

@dataclass(frozen=True)
class Card:
    id: str
    speaker: str
    title: str
    text: str
    deadline: str
CARDS = {'bus': Card('bus', '運転手', '終便の案内', '公開可: バスは時計2発、座席2。次の時計に停留所へ集まれます。', '枠0/1まで'), 'bus_late': Card('bus_late', '運転手', '終便終了を確認', '公開可: 今夜のバスは出発済み。待つ人は会館で雨宿りできます。', '締切後の案内'), 'umbrella': Card('umbrella', '傘係', '手伝いを一人募集', '公開可: 時計3に一人が迎えに来て、まだ帰れない一人を徒歩で案内します。', '枠0/1まで'), 'umbrella_late': Card('umbrella_late', '傘係', '明朝へ募集を引継ぐ', '公開可: 今夜の募集は終了。明朝の手伝い依頼を引継ぎに残してください。', '締切後の案内'), 'song': Card('song', '演奏者', '最後の歌を紹介', '公開可: 撤収前のこの一枠だけ、今夜の歌の題名を紹介できます。', '枠0だけ'), 'song_late': Card('song_late', '演奏者', '歌の紹介を次回へ', '公開可: 演奏者は撤収済み。今夜は紹介できなかったことを引継げます。', '締切後の案内'), 'shelter': Card('shelter', '会館係', '会館の開放を案内', '公開可: 会館は待てる場所です。帰宅手段が未確認でも、ここで雨宿りできます。', 'いつでも'), 'thanks': Card('thanks', '傘係', '送り先が決まった', '公開可: 徒歩の一人の送り先が決まりました。引き受けた助けを次の係にも伝えて。', '徒歩成立後'), 'repair': Card('repair', '修理係', '明日の灯りに手伝い', '公開可: 時計2に二人が集まり、灯りを準備。作業で騒がしさが1増えます。', '枠0/1まで'), 'repair_late': Card('repair_late', '修理係', '朝の手作業へ引継ぐ', '公開可: 今夜の準備募集は終了。朝の手作業を次の係へ残してください。', '締切後の案内'), 'shop': Card('shop', '店主', '閉店前の引取り', '公開可: 時計2に二箱を引き取ります。客の移動で騒がしさが2増えます。', '枠0/1まで'), 'shop_late': Card('shop_late', '店主', '箱の保管を案内', '公開可: 引取り時間は終了。残る箱は店内で保管できます。', '締切後の案内'), 'quiet': Card('quiet', '住民', '静かな帰路の案内', '公開可: 次の時計に騒がしさを2減らします。静けさは0が下限です。', '早寝の記録は時計2'), 'lights_call': Card('lights_call', '翌朝担当', '灯りの準備を確認', '公開可: 明日の灯りは準備済み。引き受けた二人の助けを記録してください。', '準備成立後'), 'morning_call': Card('morning_call', '翌朝担当', '朝の準備が残る', '公開可: 灯りは未準備です。朝に手作業が残ることを引継いでください。', '時計2以後'), 'storage_call': Card('storage_call', '店主', '保管場所の確認', '公開可: 残った二箱は店内で保管します。未引取りの数を残してください。', '時計2以後'), 'quiet_call': Card('quiet_call', '住民', '通りの記録を確認', '公開可: 時計2に記録した静けさと、現在の状態を引継いでください。', '時計2以後'), 'ferry': Card('ferry', '渡し場係', '確認済みの渡し便', '公開可: 船は時計1と4に各一人。次の時計に渡し場へ着き、到着順で乗れます。', '最初の便は枠0'), 'check': Card('check', '橋番', '橋を取材確認する', '公開可: 確認には今の時計から3刻。開閉の公式回答が届くまで通行可とは言えません。', '回答後に別の放送枠が必要'), 'bridge': Card('bridge', '橋番', '確認済みの橋を案内', '公開可: 橋は通行可と確認済み。次の時計に、まだ帰れない全員が橋で帰れます。', '開通回答後だけ'), 'bridge_closed': Card('bridge_closed', '橋番', '閉鎖の回答を案内', '公開可: 橋は閉鎖と確認済み。渡しと会館の案内を次の係へ残してください。', '閉鎖回答後だけ'), 'jingle': Card('jingle', '放送係', '短い合図と現況', '公開可: 自作の合図と確認済みの現況だけを流します。人の移動は予約しません。', '一枠を進める')}

def initial(l):
    people = tuple((Person(chr(65 + i), 'P' if l.kind == 'A' else 'H') for i in range(3 if l.kind == 'A' else 4 if l.kind == 'C' else 0)))
    return Timeline(State(people=people, calls={'A': ('bus', 'umbrella', 'song'), 'B': ('repair', 'shop', 'quiet'), 'C': ('ferry', 'check', 'shelter')}[l.kind]))

def home_count(s):
    return sum((p.place == '帰宅' for p in s.people))

def early_count(s):
    return sum((p.home_time == 1 for p in s.people))

def enabled(s, l, c):
    if s.ended or c not in CARDS:
        return False
    if c in ('jingle',):
        return True
    if c in s.sent:
        return False
    if c in ('shelter', 'thanks', 'lights_call', 'morning_call', 'storage_call', 'quiet_call'):
        return c in s.calls
    if c == 'bridge':
        return s.bridge == '開通'
    if c == 'bridge_closed':
        return s.bridge == '閉鎖'
    if c.endswith('_late'):
        base = c[:-5]
        return base in s.calls and base not in s.sent and (s.t >= (1 if base == 'song' else 2))
    if c in ('bus', 'umbrella', 'repair', 'shop'):
        return c in s.calls and s.t <= 1
    if c == 'song':
        return c in s.calls and s.t == 0
    return c in s.calls

def visible(s, l, variant=False):
    ids = []
    for c in s.calls:
        if c in s.sent:
            continue
        late = c + '_late'
        if late in CARDS and s.t >= (1 if c == 'song' else 2):
            c = late
        if c not in s.sent:
            ids.append(c)
    if l.kind == 'C' and s.bridge != '未確認':
        c = 'bridge' if s.bridge == '開通' else 'bridge_closed'
        if c not in s.sent:
            ids.append(c)
    return tuple(dict.fromkeys(ids + ['jingle']))

def preview(s, l, c):
    if not enabled(s, l, c):
        return '条件付き見本: ' + ('時計2以後に待つ人がいると着信。' if c == 'shelter' else '時計3に徒歩案内が成立すると着信。')
    if c == 'check':
        return '回答予定: 時計' + str(s.t + 3) + '。' + ('残り枠での橋案内は可能性あり。' if s.t == 0 else '回答後の橋放送は後任へ。')
    if c == 'ferry':
        return '次の時計に渡し場へ。残る便: ' + ('1と4、各一人。' if s.t == 0 else '4、一人。')
    if c == 'bridge':
        return '次の時計に残る' + str(len(s.people) - home_count(s)) + '人が橋で帰宅。'
    if c == 'bus':
        return '時計2の便で二人。案内後の到着を先に処理。'
    if c == 'umbrella':
        return '時計3に、まだ帰れない人がいれば一人を徒歩案内。'
    if c == 'quiet':
        return '次の時計に騒がしさを2減らす。同刻の作業と客の後。'
    if c == 'shelter':
        return '待てる場所を確認。帰宅人数は増えません。'
    return CARDS[c].deadline + '。' + ('時計' + str(s.t + 1) + 'へ進みます。')

def pending_text(e):
    return str(e.due) + '刻: ' + {'bus_arrive': '停留所に集合', 'walk': '徒歩の迎え', 'repair': '灯りの作業', 'shop': '箱の引取り', 'quiet': '静かな帰路', 'ferry_arrive': '渡し場に集合', 'answer': '橋の公式回答', 'bridge_home': '橋から帰宅'}[e.kind]

def process(s, l, t):
    pending = tuple((e for e in s.pending if e.due != t))
    due = sorted((e for e in s.pending if e.due == t), key=lambda e: (e.priority, e.kind))
    people = list(s.people)
    kw = {'t': t, 'pending': pending}
    noise = s.noise
    lights = s.lights
    boxes = s.boxes
    walkers = s.walkers
    bridge = s.bridge
    for e in due:
        if e.kind in ('bus_arrive', 'ferry_arrive'):
            dest = 'B' if e.kind == 'bus_arrive' else 'F'
            people = [replace(p, place=dest, arrival=t) if p.place != '帰宅' else p for p in people]
        elif e.kind == 'repair':
            lights = True
            noise += 1
        elif e.kind == 'shop':
            boxes = 0
            noise += 2
        elif e.kind == 'quiet':
            noise = max(0, noise - 2)
        elif e.kind == 'walk':
            candidates = [p for p in people if p.place != '帰宅']
            if candidates:
                who = min(candidates, key=lambda p: p.id).id
                people = [replace(p, place='帰宅', home_time=t) if p.id == who else p for p in people]
                walkers += 1
        elif e.kind == 'answer':
            bridge = '開通' if l.bridge_open else '閉鎖'
        elif e.kind == 'bridge_home':
            people = [replace(p, place='帰宅', home_time=t) if p.place != '帰宅' else p for p in people]
    if l.kind == 'A' and t == 2:
        riders = sorted((p for p in people if p.place == 'B'), key=lambda p: (p.arrival, p.id))[:2]
        ids = {p.id for p in riders}
        people = [replace(p, place='帰宅', home_time=t) if p.id in ids else replace(p, place='H') if p.place != '帰宅' else p for p in people]
    if l.kind == 'C' and t in (1, 4):
        riders = sorted((p for p in people if p.place == 'F'), key=lambda p: (p.arrival, p.id))[:1]
        ids = {p.id for p in riders}
        people = [replace(p, place='帰宅', home_time=t) if p.id in ids else p for p in people]
    if l.kind == 'B' and t == 2:
        kw['early_quiet'] = int(noise <= 1)
    s = replace(s, **kw, people=tuple(people), noise=noise, lights=lights, boxes=boxes, walkers=walkers, bridge=bridge)
    add = []
    if l.kind == 'A':
        if t >= 2 and home_count(s) < len(s.people):
            add.append('shelter')
        if walkers:
            add.append('thanks')
    if l.kind == 'B' and t >= 2:
        add.extend(['lights_call' if lights else 'morning_call', 'quiet_call'])
        if boxes:
            add.append('storage_call')
    return replace(s, calls=tuple(dict.fromkeys(s.calls + tuple(sorted(add)))))

def transition(s, l, a, variant=False):
    if s.ended:
        return s
    if a.startswith('read:'):
        c = a[5:]
        return replace(s, read=s.read + (c,)) if c in visible(s, l, variant) and c not in s.read else s
    if not a.startswith('send:'):
        return s
    c = a[5:]
    if not enabled(s, l, c):
        return s
    pending = list(s.pending)
    kw = {'sent': s.sent + (c,)}
    event = {'bus': (s.t + 1, 0, 'bus_arrive'), 'umbrella': (3, 0, 'walk'), 'repair': (2, 0, 'repair'), 'shop': (2, 1, 'shop'), 'quiet': (s.t + 1, 2, 'quiet'), 'ferry': (s.t + 1, 0, 'ferry_arrive'), 'check': (s.t + 3, 1, 'answer'), 'bridge': (s.t + 1, 0, 'bridge_home')}.get(c)
    if event:
        pending.append(Event(*event))
    if c == 'shelter':
        kw['shelter'] = True
    if c == 'song':
        kw['song'] = True
    s = process(replace(s, pending=tuple(sorted(pending, key=lambda e: (e.due, e.priority, e.kind))), **kw), l, s.t + 1)
    return replace(s, ended=s.t >= 4, feedback='四枠を終了。結果と残ることを引継ぎました。' if s.t >= 4 else CARDS[c].title + 'を確定。次の電話と予定を確認。')

def step(t, l, a, variant=False):
    n = transition(t.state, l, a, variant)
    return Timeline(n)

def result_lines(s, l):
    lines = []
    if s.people:
        lines += ['帰宅 ' + str(home_count(s)) + '人 / 待つ ' + str(len(s.people) - home_count(s)) + '人', '時計1の帰宅 ' + str(early_count(s)) + '人 / 待機場所の確認 ' + ('済' if s.shelter else '未放送')]
    if l.kind == 'A':
        lines += ['徒歩の助け ' + str(s.walkers) + '人 / 歌の紹介 ' + ('できた' if s.song else '後任へ未紹介' if s.t == 0 else '今夜は残せず')]
    if l.kind == 'B':
        lines += ['灯り ' + ('準備済' if s.lights else '朝の手作業が残る') + ' / 箱 ' + str(s.boxes) + '箱', '騒がしさ ' + str(s.noise) + ' / ' + ('早い静けさ' if s.early_quiet == 1 else '時計2は未記録' if s.early_quiet < 0 else '遅くなったが落ち着いた' if s.noise <= 1 else 'まだ静かな帰路が必要')]
    if l.kind == 'C':
        lines += ['橋 ' + s.bridge + ' / ' + ('通行可の案内済' if 'bridge' in s.sent else '橋の案内は未放送')]
    lines += ['予約: ' + pending_text(e) for e in s.pending]
    return tuple(lines)

def unresolved(s, l):
    out = []
    if l.kind == 'A':
        if not s.song:
            out.append('歌の紹介は未対応。枠0の機会を後任へ' if s.t == 0 else '今夜の歌は紹介できなかった')
        if s.t < 2 and 'bus' not in s.sent:
            out.append('終便案内が未対応。枠1まで')
        if s.t < 2 and 'umbrella' not in s.sent:
            out.append('傘募集が未対応。枠1まで')
        if home_count(s) < len(s.people):
            out.append(str(len(s.people) - home_count(s)) + '人の帰路と会館待機を引継ぐ')
    if l.kind == 'B':
        if not s.lights:
            out.append('明朝の灯り準備')
        if s.boxes:
            out.append(str(s.boxes) + '箱を店内保管')
        if s.noise > 1 or (s.t < 2 and 'quiet' not in s.sent):
            out.append('静かな帰路の案内。時計2の記録を確認')
    if l.kind == 'C':
        if 'ferry' not in s.sent and s.t < 4:
            out.append('渡し場の案内が未対応。残る便は' + ('1/4' if s.t == 0 else '4') + '刻')
        if s.bridge == '未確認':
            out.append('橋の開閉は未確認')
        elif s.bridge == '開通' and 'bridge' not in s.sent:
            out.append('確認済みの橋案内は後任へ')
        if home_count(s) < len(s.people):
            out.append(str(len(s.people) - home_count(s)) + '人の帰路を引継ぐ')
    out.extend((pending_text(e) for e in s.pending))
    return tuple(out) or ('追加の帰路課題なし。放送記録を後任へ。',)

def canonical(o):
    return json.dumps(o, ensure_ascii=False, sort_keys=True, separators=(',', ':'))

def digest(o):
    return hashlib.sha256(canonical(o).encode('utf-8')).hexdigest()

def replay(l, actions, variant=False):
    if len(actions) > 512:
        raise ValueError('max 512 actions')
    t = initial(l)
    rows = []
    for k, a in enumerate(actions):
        t = step(t, l, a, variant)
        rows.append({'tick': k, 'action': a, 'state': asdict(t.state), 'sha256': digest(asdict(t.state))})
    return {'version': VERSION, 'level': l.id, 'preview_followup': variant, 'events': rows, 'final': asdict(t.state), 'result_lines': result_lines(t.state, l), 'unresolved': unresolved(t.state, l), 'sha256': digest(asdict(t.state))}

# 教材用の入力・表示。モデル以外に原作との違いがあります。
STAGE = 4
INITIAL_CURSOR = 0
KEYS = ('UP','DOWN','Z','RETURN','X','R','1','2','3','4','B')
CARD_LABELS = {
 'bus':'BUS: TWO SEATS AT T2', 'bus_late':'BUS: DEPARTED',
 'umbrella':'HELPER: ONE PERSON AT T3', 'umbrella_late':'HELPER: NEXT MORNING',
 'song':'SONG: ONLY SLOT 0', 'song_late':'SONG: MISSED TONIGHT',
 'shelter':'SHELTER: A PLACE TO WAIT', 'thanks':'THANKS: WALK ARRANGED',
 'repair':'LIGHTS: WORK AT T2 (+1)', 'repair_late':'LIGHTS: MORNING WORK',
 'shop':'BOXES: PICKUP AT T2 (+2)', 'shop_late':'BOXES: STORE THEM',
 'quiet':'QUIET: NEXT TICK (-2)', 'lights_call':'CALL: LIGHTS PREPARED',
 'morning_call':'CALL: LIGHTS STILL NEEDED', 'storage_call':'CALL: STORE TWO BOXES',
 'quiet_call':'CALL: EARLY AND NOW', 'ferry':'FERRY: ONE AT T1 AND T4',
 'check':'CHECK: ANSWER IN THREE TICKS', 'bridge':'BRIDGE: CONFIRMED OPEN',
 'bridge_closed':'BRIDGE: CONFIRMED CLOSED', 'jingle':'JINGLE: TEXT SIGNAL / +1 TICK',
 'handoff':'HANDOFF: CLOSE AT CURRENT TICK'}

class App:
    def __init__(self):
        self.level = 0
        self.variant = False
        self.reset()
    @property
    def board(self):
        return LEVELS[self.level]
    @property
    def state(self):
        return self.timeline.state
    def reset(self):
        self.timeline = initial(self.board)
        self.cursor = INITIAL_CURSOR
        self.gated = False
        self.message = 'READING DOES NOT SPEND A SLOT'
    def ids(self):
        return visible(self.state,self.board,self.variant)
    def action(self,action):
        self.timeline = step(self.timeline,self.board,action,self.variant)
        self.cursor = min(self.cursor,len(self.ids())-1)
    def press(self,key):
        if key == 'R':
            self.reset()
            return
        if key in ('1','2','3','4') and int(key) <= len(LEVELS):
            self.level = int(key)-1
            self.reset()
            return
        if key == 'B' and STAGE == 5 and self.board.kind == 'A':
            self.variant = not self.variant
            self.reset()
            return
        if key == 'X' and STAGE == 5:
            self.action('undo')
            return
        if self.state.ended:
            return
        if key in ('UP','DOWN'):
            self.cursor = (self.cursor + (-1 if key=='UP' else 1)) % len(self.ids())
        elif key in ('Z','RETURN'):
            self.action(('read:' if key=='Z' else 'send:') + self.ids()[self.cursor])
    def control(self,pressed,held):
        # 合成テストとPyxelの両方がこの入口を使う。全部離すまで次を受けない。
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.message = 'RELEASE ALL KEYS / CHORD REJECTED'
            self.gated = True
            return
        if len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True
    def notice(self,c):
        s = self.state
        if s.ended:
            return ('CLOSED: PENDING IS NOT ACHIEVED', 'X UNDO (STAGE 5) / R NEW TRIAL')
        if not enabled(s,self.board,c):
            return ('CONDITIONAL SAMPLE ONLY', 'WAIT FOR ITS ACTUAL CALL')
        if c == 'check':
            return ('ANSWER DUE AT T' + str(s.t+3), 'A LATER BROADCAST IS STILL REQUIRED')
        if c == 'bridge':
            return ('CONFIRMED OPEN: HOME NEXT TICK', 'ONLY THIS BROADCAST RESERVES RETURN')
        if c == 'bridge_closed':
            return ('CONFIRMED CLOSED', 'NO BRIDGE RETURN IS RESERVED')
        if c == 'handoff':
            return ('CLOSE NOW / NO CLOCK ADVANCE', 'KEEP PENDING AND UNKNOWN ITEMS')
        if c == 'shelter':
            return ('CONFIRM A PLACE TO WAIT', 'HOME COUNT DOES NOT INCREASE')
        if c == 'ferry':
            return ('ARRIVE NEXT TICK, THEN DEPART', 'BOATS AT T1 / T4, ONE SEAT EACH')
        if c == 'bus':
            return ('ARRIVE NEXT TICK, THEN DEPART', 'BUS AT T2, TWO SEATS')
        if c == 'umbrella':
            return ('ONE WALK HOME AT T3', 'ONLY IF SOMEONE IS STILL WAITING')
        if c == 'quiet':
            return ('AT NEXT TICK: MAX(0, NOISE - 2)', 'AFTER REPAIR AND SHOP AT SAME TICK')
        return ('Z READ / ENTER BROADCAST', 'CHECK DEADLINES BEFORE SENDING')
    def draw(self,p):
        s=self.state
        p.cls(1)
        p.text(14,12,'STRANDED RADIO / STAGE '+str(STAGE)+' / NIGHT '+self.board.id,10)
        p.text(14,27,'FICTIONAL PEOPLE / SILENT TEXT SIGNALS',6)
        p.text(14,47,'TICK '+str(s.t)+' / 4    '+('CLOSED' if s.ended else 'ON AIR')+'    HISTORY '+str(len(self.timeline.past)),11)
        for i in range(4):
            p.rect(440+i*58,46,50,8,11 if i<s.t else 5)
        p.text(14,72,'INCOMING VOICES',10)
        ids=self.ids()
        start=max(0,self.cursor-6)
        for j,c in enumerate(ids[start:start+7]):
            y=88+j*33
            allowed=enabled(s,self.board,c)
            p.rectb(12,y,298,29,10 if start+j==self.cursor else 5)
            p.text(19,y+5,CARD_LABELS[c],7 if allowed else 13)
            label='READ' if c in s.read else 'UNREAD'
            if not allowed and not s.ended:label='CONDITIONAL SAMPLE'
            p.text(19,y+17,label,6)
        p.text(14,326,str(self.cursor+1)+' / '+str(len(ids))+'   B SAMPLES: '+str(self.variant).upper(),6)
        c=ids[min(self.cursor,len(ids)-1)]
        p.text(330,74,'SELECTED: '+c.upper(),10)
        for j,line in enumerate(self.notice(c)):p.text(330,93+j*14,line,7)
        p.text(330,130,'CONFIRMED CURRENT STATE',10)
        p.text(330,149,'SENT: '+(', '.join(s.sent) or 'NONE'),7)
        if STAGE == 1:
            p.text(330,173,'SONG SENT: '+str('song' in s.sent).upper(),11)
            p.text(330,192,'NO TRANSPORT MODEL IN THIS STAGE',6)
        else:
            if self.board.kind in ('A','C'):
                p.text(330,171,'HOME '+str(home_count(s))+' / WAIT '+str(len(s.people)-home_count(s)),11)
                p.text(330,189,'SHELTER CONFIRMED: '+str(s.shelter).upper(),7)
                labels={'P':'SQUARE','H':'HALL','B':'BUS','F':'FERRY','帰宅':'HOME'}
                for j,person in enumerate(s.people):
                    p.text(330+j*86,211,person.id+':'+labels[person.place],7)
                    p.text(330+j*86,224,'AT '+str(person.home_time) if person.place=='帰宅' else 'WAIT',6)
            if self.board.kind=='A':p.text(330,247,'WALK '+str(s.walkers)+' / SONG '+str(s.song).upper(),7)
            if self.board.kind=='B':
                p.text(330,171,'LIGHTS '+str(s.lights).upper()+' / BOXES '+str(s.boxes),11)
                p.text(330,189,'NOISE NOW '+str(s.noise),7)
                p.text(330,211,'EARLY QUIET AT T2: '+('NOT RECORDED' if s.early_quiet<0 else str(bool(s.early_quiet)).upper()),7)
            if self.board.kind=='C':
                p.text(330,247,'BRIDGE: '+{'未確認':'UNKNOWN','開通':'OPEN','閉鎖':'CLOSED'}[s.bridge],11)
                p.text(330,262,'BRIDGE BROADCAST: '+str('bridge' in s.sent).upper(),7)
            p.text(330,285,'PENDING / NOT ACHIEVED',10)
            for j,event in enumerate(s.pending):
                p.text(330,303+j*14,'T'+str(event.due)+' P'+str(event.priority)+' '+event.kind.upper(),7)
            if not s.pending:p.text(330,303,'NONE',6)
        p.line(14,351,702,351,5)
        if s.ended:
            p.text(14,365,'HANDOVER / CURRENT FACTS AND REMAINING WORK',10)
            if STAGE==1:
                p.text(14,384,'SONG: '+('INTRODUCED' if 'song' in s.sent else 'NOT INTRODUCED TONIGHT'),7)
            else:
                for j,line in enumerate(handover_lines(s,self.board)[:5]):p.text(14,384+j*15,line,7)
        else:
            p.text(14,365,'NO CLOCK ADVANCE WHILE READING OR THINKING',10)
            p.text(14,384,'ARRIVALS / WORK -> DEPARTURES / EARLY RECORD -> NEW CALLS -> END',6)
            p.text(14,405,'SLOTS ARE LIMITED. THERE IS NO HIDDEN ALL-HAPPY SCORE.',7)
        p.text(14,469,self.message,10)
        p.text(14,488,'UP/DOWN SELECT / Z READ / ENTER SEND / X UNDO (5) / R RESET',7)
        p.text(14,505,'1-'+str(len(LEVELS))+' NIGHT / B A-SAMPLES (5) / ESC QUIT / RELEASE EACH KEY',6)

def handover_lines(s,l):
    lines=[]
    if l.kind=='A':
        if not s.song:lines.append('SONG: OPPORTUNITY STILL IN SLOT 0' if s.t==0 else 'SONG: MISSED TONIGHT')
        if s.t<2 and 'bus' not in s.sent:lines.append('BUS ANNOUNCEMENT STILL NEEDED BY SLOT 1')
        if s.t<2 and 'umbrella' not in s.sent:lines.append('HELPER REQUEST STILL NEEDED BY SLOT 1')
    if l.kind=='B':
        if not s.lights:lines.append('MORNING LIGHTING WORK REMAINS')
        if s.boxes:lines.append(str(s.boxes)+' BOXES REMAIN FOR STORAGE')
        if s.noise>1 or s.t<2 and 'quiet' not in s.sent:lines.append('QUIET ROUTE / CHECK T2 RECORD')
    if l.kind=='C':
        if 'ferry' not in s.sent and s.t<4:lines.append('FERRY ANNOUNCEMENT STILL NEEDED')
        if s.bridge=='未確認':lines.append('BRIDGE OPEN/CLOSED IS UNKNOWN')
        elif s.bridge=='開通' and 'bridge' not in s.sent:lines.append('BRIDGE CONFIRMED OPEN, NOT BROADCAST')
    if s.people and home_count(s)<len(s.people):lines.append(str(len(s.people)-home_count(s))+' PEOPLE STILL NEED A ROUTE / WAITING PLACE')
    lines.extend('PENDING T'+str(e.due)+': '+e.kind.upper() for e in s.pending)
    return lines or ['NO ADDITIONAL ROUTE TASK / KEEP THE BROADCAST RECORD']

def demo_actions():
    return ('4', 'send:check', 'send:ferry', 'send:jingle')

def demo_keys():
    app=App()
    keys=[]
    for action in demo_actions():
        if action in ('1','2','3','4','X'):
            keys.append(action);app.press(action);continue
        verb,c=action.split(':',1)
        while app.ids()[app.cursor]!=c:
            keys.append('DOWN');app.press('DOWN')
        key='Z' if verb=='read' else 'RETURN'
        keys.append(key);app.press(key)
    return tuple(keys)

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(720,530,title='Stranded Radio lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App()
    sequence=demo_keys() if demo else ()
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    limit=max(12,2*len(sequence)+6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            index=p.frame_count//2
            pressed={sequence[index]} if demo and p.frame_count%2==0 and index<len(sequence) else set()
            app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    # Pyxelの初期化でcwdが変わる場合にも、出力先は呼出元cwdに固定する。
    if args.screenshot:
        target=Path(args.screenshot).resolve()
        if target.suffix.lower()!='.png' or not target.parent.is_dir() or target.exists():
            parser.error('use a new .png in an existing directory')
        args.screenshot=str(target)
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 05 / 05

未完の仕事を正直に残し、放送ごと巻き戻す

早めに放送を終える(早期閉局)、書き換えないスナップショット(その時点の状態の写し)、見せてよい情報の境目も元に戻す。

前提は、第4段階と、tuple の最後の要素の参照とスライスです。完成版では、元のゲームの model.py の定義とデータを、すべてそのまま使います。handoff(引き継ぎ)を選んで Enter を押すと、今の時計のまま放送を終えます(閉局)。新しい枠は使わず、process も呼びません。check→handoff なら、時計1・橋は未確認のままで、時計3の答えの予約が残ります。未来の予約を全部進めてから成績にする、という処理はありません。閉局の画面には、今の結果と、残っている仕事をいっしょに表示します。

step は、放送する前の State を past(過去の履歴)に保存します。読む操作は既読を変えますが、履歴は増やしません。X を押すと、past の最後の State に、まるごと戻ります。時刻、居場所、既読、着信、予約、橋の確認、閉局の状態を、すべていっしょに元に戻します。たとえば check→handoff→X なら、時計1でまた放送できます。もう1回 X を押すと、check の前の、時計0・未確認・予約なしの状態に戻ります。

読む操作は、直前の放送に続けて読んだものとして扱います。bus を放送→song_late を読む→X と押すと、bus の前の状態に戻るので、その既読も消えます。一方、放送より前から読んであった印は、保存した State に入っているので、戻したあとも残ります。受け付けない放送、同じものを2回放送すること、終わったあとの放送では、履歴は増えません。上限の64は、今の past の長さの上限です。Undo で past が短くなれば、また放送できます。これまでの判断の合計回数の上限、とまちがえないようにします。ふつうの4枠の遊びでは、この長さにはなりません。

B キーは、夜Aだけで使えます。あとで届く shelter と thanks を、最初から「条件付きの見本」として見せて比べるためのものです。見本は読めても、条件がそろう前には放送できません。夜Cでは、B キーを押しても何も起きません。見本と実際の着信、読むことと放送すること、確かめることと実行すること。これらを別々の条件で扱うのが、この作品の中心です。英字(ASCII)の表示、同時押しをすべて受け付けないこと、コマンドライン(CLI)での画像の保存は、教材のためのものです。表紙、日本語の文字(グリフ)、原稿のページ、P・M・S・F12 を使う元のゲームの画面の操作は、次に読む部分として残しておきます。

元のゲームに進むときは、app.py の update にある Z を優先する処理、draw の原稿のページ、export の記録を読みます。元のゲームには、独立した controller.py はありません。

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

05_handoff_undo.py / 注目する部分
 if a=='undo':return Timeline(t.past[-1],t.past[:-1]) if t.past else t
 if len(t.past)>=64:return t

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_handoff_undo.py
macOS / Linux
.venv/bin/python 05_handoff_undo.py
画面で見ること4→check→handoff で、TICK1・CLOSED・BRIDGE UNKNOWN・PENDING T3 ANSWER になります。X で TICK1・ON AIR(放送中)、さらに X で TICK0・予約なしに戻ります。R→1→B→下を3回押して shelter を選ぶと、CONDITIONAL SAMPLE(条件付きの見本)と出ます。Enter を押しても、時計は0のままです。さらに下を1回押して thanks を選び、Enter を押しても時計は0です。B で切り替えた直後は bus が選ばれているので、見本を選び直します。
  • 閉局では、時計を進めない
  • State をまるごと Undo する
  • 条件付きの見本を、実際の着信と分ける
つまずきやすいところUndo で時計だけを戻すと、答えや電話が未来の状態のまま残ってしまいます。また、handoff で process を呼ぶと、まだできていない予約を、勝手にできたことにしてしまいます。履歴の長さ64は、合計64回という意味ではありません。
少し変えてみよう

step の if len(t.past)>=64:return t を、if len(t.past)>=2:return t に1か所だけ変えます。保存して終了し、もう一度実行します。最初の夜Aで jingle を2回放送すると、時計2です。3回目は受け付けられません。X で時計1・履歴1に戻してから jingle を放送すると、また時計2に進みます。予告や説明が表示されていても、履歴の上限は別に入力を断ることを確かめましょう。確かめたら64に戻し、保存・終了・もう一度実行します。R だけでは、書き換えたコードは読み直されません。

05_handoff_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(433行)
05_handoff_undo.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.

"""帰れないラジオ局・教材5/5。登場人物・町・放送はすべて架空です。"""
"""070: finite broadcasts, scheduled effects and honest handover. Pure stdlib."""
from dataclasses import dataclass,replace,asdict
import json,hashlib
VERSION='070-v1'
@dataclass(frozen=True)
class Level:
 id:str; title:str; kind:str; bridge_open:bool=False
LEVELS=(Level('A','最後の便と最後の歌','A'),Level('B','明日の市と眠る通り','B'),Level('C1','まだ確かめていない橋','C'),Level('C2','橋の確認用の別夜','C',True))
@dataclass(frozen=True)
class Person:
 id:str; place:str='H'; arrival:int=0; home_time:int=0
@dataclass(frozen=True)
class Event:
 due:int; priority:int; kind:str
@dataclass(frozen=True)
class State:
 t:int=0; sent:tuple=(); pending:tuple=(); people:tuple=(); calls:tuple=(); read:tuple=(); shelter:bool=False; song:bool=False; walkers:int=0; noise:int=1; lights:bool=False; boxes:int=2; early_quiet:int=-1; bridge:str='未確認'; ended:bool=False; feedback:str='声を選んで読む。Enterで一枠を確定します。'
@dataclass(frozen=True)
class Timeline:
 state:State; past:tuple=()
@dataclass(frozen=True)
class Card:
 id:str; speaker:str; title:str; text:str; deadline:str
CARDS={
 'bus':Card('bus','運転手','終便の案内','公開可: バスは時計2発、座席2。次の時計に停留所へ集まれます。','枠0/1まで'),
 'bus_late':Card('bus_late','運転手','終便終了を確認','公開可: 今夜のバスは出発済み。待つ人は会館で雨宿りできます。','締切後の案内'),
 'umbrella':Card('umbrella','傘係','手伝いを一人募集','公開可: 時計3に一人が迎えに来て、まだ帰れない一人を徒歩で案内します。','枠0/1まで'),
 'umbrella_late':Card('umbrella_late','傘係','明朝へ募集を引継ぐ','公開可: 今夜の募集は終了。明朝の手伝い依頼を引継ぎに残してください。','締切後の案内'),
 'song':Card('song','演奏者','最後の歌を紹介','公開可: 撤収前のこの一枠だけ、今夜の歌の題名を紹介できます。','枠0だけ'),
 'song_late':Card('song_late','演奏者','歌の紹介を次回へ','公開可: 演奏者は撤収済み。今夜は紹介できなかったことを引継げます。','締切後の案内'),
 'shelter':Card('shelter','会館係','会館の開放を案内','公開可: 会館は待てる場所です。帰宅手段が未確認でも、ここで雨宿りできます。','いつでも'),
 'thanks':Card('thanks','傘係','送り先が決まった','公開可: 徒歩の一人の送り先が決まりました。引き受けた助けを次の係にも伝えて。','徒歩成立後'),
 'repair':Card('repair','修理係','明日の灯りに手伝い','公開可: 時計2に二人が集まり、灯りを準備。作業で騒がしさが1増えます。','枠0/1まで'),
 'repair_late':Card('repair_late','修理係','朝の手作業へ引継ぐ','公開可: 今夜の準備募集は終了。朝の手作業を次の係へ残してください。','締切後の案内'),
 'shop':Card('shop','店主','閉店前の引取り','公開可: 時計2に二箱を引き取ります。客の移動で騒がしさが2増えます。','枠0/1まで'),
 'shop_late':Card('shop_late','店主','箱の保管を案内','公開可: 引取り時間は終了。残る箱は店内で保管できます。','締切後の案内'),
 'quiet':Card('quiet','住民','静かな帰路の案内','公開可: 次の時計に騒がしさを2減らします。静けさは0が下限です。','早寝の記録は時計2'),
 'lights_call':Card('lights_call','翌朝担当','灯りの準備を確認','公開可: 明日の灯りは準備済み。引き受けた二人の助けを記録してください。','準備成立後'),
 'morning_call':Card('morning_call','翌朝担当','朝の準備が残る','公開可: 灯りは未準備です。朝に手作業が残ることを引継いでください。','時計2以後'),
 'storage_call':Card('storage_call','店主','保管場所の確認','公開可: 残った二箱は店内で保管します。未引取りの数を残してください。','時計2以後'),
 'quiet_call':Card('quiet_call','住民','通りの記録を確認','公開可: 時計2に記録した静けさと、現在の状態を引継いでください。','時計2以後'),
 'ferry':Card('ferry','渡し場係','確認済みの渡し便','公開可: 船は時計1と4に各一人。次の時計に渡し場へ着き、到着順で乗れます。','最初の便は枠0'),
 'check':Card('check','橋番','橋を取材確認する','公開可: 確認には今の時計から3刻。開閉の公式回答が届くまで通行可とは言えません。','回答後に別の放送枠が必要'),
 'bridge':Card('bridge','橋番','確認済みの橋を案内','公開可: 橋は通行可と確認済み。次の時計に、まだ帰れない全員が橋で帰れます。','開通回答後だけ'),
 'bridge_closed':Card('bridge_closed','橋番','閉鎖の回答を案内','公開可: 橋は閉鎖と確認済み。渡しと会館の案内を次の係へ残してください。','閉鎖回答後だけ'),
 'jingle':Card('jingle','放送係','短い合図と現況','公開可: 自作の合図と確認済みの現況だけを流します。人の移動は予約しません。','一枠を進める'),
 'handoff':Card('handoff','放送係','引継いで閉局','未対応の声、予約、未確認事項をそのまま後任へ残して閉局します。','時計を進めず終了')}
def initial(l):
 people=tuple(Person(chr(65+i),'P' if l.kind=='A' else 'H') for i in range(3 if l.kind=='A' else 4 if l.kind=='C' else 0))
 return Timeline(State(people=people,calls={'A':('bus','umbrella','song'),'B':('repair','shop','quiet'),'C':('ferry','check','shelter')}[l.kind]))
def home_count(s):return sum(p.place=='帰宅' for p in s.people)
def early_count(s):return sum(p.home_time==1 for p in s.people)
def enabled(s,l,c):
 if s.ended or c not in CARDS:return False
 if c in ('jingle','handoff'):return True
 if c in s.sent:return False
 if c in ('shelter','thanks','lights_call','morning_call','storage_call','quiet_call'):return c in s.calls
 if c=='bridge':return s.bridge=='開通'
 if c=='bridge_closed':return s.bridge=='閉鎖'
 if c.endswith('_late'):
  base=c[:-5];return base in s.calls and base not in s.sent and s.t>=(1 if base=='song' else 2)
 if c in ('bus','umbrella','repair','shop'):return c in s.calls and s.t<=1
 if c=='song':return c in s.calls and s.t==0
 return c in s.calls
def visible(s,l,variant=False):
 ids=[]
 for c in s.calls:
  if c in s.sent:continue
  late=c+'_late'
  if late in CARDS and s.t>=(1 if c=='song' else 2):c=late
  if c not in s.sent:ids.append(c)
 if l.kind=='C' and s.bridge!='未確認':
  c='bridge' if s.bridge=='開通' else 'bridge_closed'
  if c not in s.sent:ids.append(c)
 if l.kind=='A' and variant:
  for c in ('shelter','thanks'):
   if c not in ids and c not in s.sent:ids.append(c)
 return tuple(dict.fromkeys(ids+['jingle','handoff']))
def preview(s,l,c):
 if not enabled(s,l,c):return '条件付き見本: '+('時計2以後に待つ人がいると着信。' if c=='shelter' else '時計3に徒歩案内が成立すると着信。')
 if c=='check':return '回答予定: 時計'+str(s.t+3)+'。'+('残り枠での橋案内は可能性あり。' if s.t==0 else '回答後の橋放送は後任へ。')
 if c=='ferry':return '次の時計に渡し場へ。残る便: '+('1と4、各一人。' if s.t==0 else '4、一人。')
 if c=='bridge':return '次の時計に残る'+str(len(s.people)-home_count(s))+'人が橋で帰宅。'
 if c=='bus':return '時計2の便で二人。案内後の到着を先に処理。'
 if c=='umbrella':return '時計3に、まだ帰れない人がいれば一人を徒歩案内。'
 if c=='quiet':return '次の時計に騒がしさを2減らす。同刻の作業と客の後。'
 if c=='handoff':return '今の時計で閉局。予約の未来結果は未達成として残す。'
 if c=='shelter':return '待てる場所を確認。帰宅人数は増えません。'
 return CARDS[c].deadline+'。'+('時計'+str(s.t+1)+'へ進みます。')
def pending_text(e):return str(e.due)+'刻: '+{'bus_arrive':'停留所に集合','walk':'徒歩の迎え','repair':'灯りの作業','shop':'箱の引取り','quiet':'静かな帰路','ferry_arrive':'渡し場に集合','answer':'橋の公式回答','bridge_home':'橋から帰宅'}[e.kind]
def process(s,l,t):
 pending=tuple(e for e in s.pending if e.due!=t);due=sorted((e for e in s.pending if e.due==t),key=lambda e:(e.priority,e.kind));people=list(s.people);kw={'t':t,'pending':pending}
 noise=s.noise;lights=s.lights;boxes=s.boxes;walkers=s.walkers;bridge=s.bridge
 for e in due:
  if e.kind in ('bus_arrive','ferry_arrive'):
   dest='B' if e.kind=='bus_arrive' else 'F';people=[replace(p,place=dest,arrival=t) if p.place!='帰宅' else p for p in people]
  elif e.kind=='repair':lights=True;noise+=1
  elif e.kind=='shop':boxes=0;noise+=2
  elif e.kind=='quiet':noise=max(0,noise-2)
  elif e.kind=='walk':
   candidates=[p for p in people if p.place!='帰宅']
   if candidates:
    who=min(candidates,key=lambda p:p.id).id;people=[replace(p,place='帰宅',home_time=t) if p.id==who else p for p in people];walkers+=1
  elif e.kind=='answer':bridge='開通' if l.bridge_open else '閉鎖'
  elif e.kind=='bridge_home':people=[replace(p,place='帰宅',home_time=t) if p.place!='帰宅' else p for p in people]
 if l.kind=='A' and t==2:
  riders=sorted((p for p in people if p.place=='B'),key=lambda p:(p.arrival,p.id))[:2];ids={p.id for p in riders};people=[replace(p,place='帰宅',home_time=t) if p.id in ids else replace(p,place='H') if p.place!='帰宅' else p for p in people]
 if l.kind=='C' and t in (1,4):
  riders=sorted((p for p in people if p.place=='F'),key=lambda p:(p.arrival,p.id))[:1];ids={p.id for p in riders};people=[replace(p,place='帰宅',home_time=t) if p.id in ids else p for p in people]
 if l.kind=='B' and t==2:kw['early_quiet']=int(noise<=1)
 s=replace(s,**kw,people=tuple(people),noise=noise,lights=lights,boxes=boxes,walkers=walkers,bridge=bridge)
 add=[]
 if l.kind=='A':
  if t>=2 and home_count(s)<len(s.people):add.append('shelter')
  if walkers:add.append('thanks')
 if l.kind=='B' and t>=2:
  add.extend(['lights_call' if lights else 'morning_call','quiet_call'])
  if boxes:add.append('storage_call')
 return replace(s,calls=tuple(dict.fromkeys(s.calls+tuple(sorted(add)))))
def transition(s,l,a,variant=False):
 if s.ended:return s
 if a.startswith('read:'):
  c=a[5:]
  return replace(s,read=s.read+(c,)) if c in visible(s,l,variant) and c not in s.read else s
 if not a.startswith('send:'):return s
 c=a[5:]
 if not enabled(s,l,c):return s
 if c=='handoff':return replace(s,ended=True,feedback='今の状態と未対応事項を引継ぎました。')
 pending=list(s.pending);kw={'sent':s.sent+(c,)}
 event={'bus':(s.t+1,0,'bus_arrive'),'umbrella':(3,0,'walk'),'repair':(2,0,'repair'),'shop':(2,1,'shop'),'quiet':(s.t+1,2,'quiet'),'ferry':(s.t+1,0,'ferry_arrive'),'check':(s.t+3,1,'answer'),'bridge':(s.t+1,0,'bridge_home')}.get(c)
 if event:pending.append(Event(*event))
 if c=='shelter':kw['shelter']=True
 if c=='song':kw['song']=True
 s=process(replace(s,pending=tuple(sorted(pending,key=lambda e:(e.due,e.priority,e.kind))),**kw),l,s.t+1)
 return replace(s,ended=s.t>=4,feedback='四枠を終了。結果と残ることを引継ぎました。' if s.t>=4 else CARDS[c].title+'を確定。次の電話と予定を確認。')
def step(t,l,a,variant=False):
 if a=='undo':return Timeline(t.past[-1],t.past[:-1]) if t.past else t
 if len(t.past)>=64:return t
 n=transition(t.state,l,a,variant)
 if a.startswith('read:'):return Timeline(n,t.past)
 return Timeline(n,t.past+(t.state,)) if n!=t.state else t
def result_lines(s,l):
 lines=[]
 if s.people:lines+=['帰宅 '+str(home_count(s))+'人 / 待つ '+str(len(s.people)-home_count(s))+'人','時計1の帰宅 '+str(early_count(s))+'人 / 待機場所の確認 '+('済' if s.shelter else '未放送')]
 if l.kind=='A':lines+=['徒歩の助け '+str(s.walkers)+'人 / 歌の紹介 '+('できた' if s.song else '後任へ未紹介' if s.t==0 else '今夜は残せず')]
 if l.kind=='B':lines+=['灯り '+('準備済' if s.lights else '朝の手作業が残る')+' / 箱 '+str(s.boxes)+'箱','騒がしさ '+str(s.noise)+' / '+('早い静けさ' if s.early_quiet==1 else '時計2は未記録' if s.early_quiet<0 else '遅くなったが落ち着いた' if s.noise<=1 else 'まだ静かな帰路が必要')]
 if l.kind=='C':lines+=['橋 '+s.bridge+' / '+('通行可の案内済' if 'bridge' in s.sent else '橋の案内は未放送')]
 lines+=['予約: '+pending_text(e) for e in s.pending]
 return tuple(lines)
def unresolved(s,l):
 out=[]
 if l.kind=='A':
  if not s.song:out.append('歌の紹介は未対応。枠0の機会を後任へ' if s.t==0 else '今夜の歌は紹介できなかった')
  if s.t<2 and 'bus' not in s.sent:out.append('終便案内が未対応。枠1まで')
  if s.t<2 and 'umbrella' not in s.sent:out.append('傘募集が未対応。枠1まで')
  if home_count(s)<len(s.people):out.append(str(len(s.people)-home_count(s))+'人の帰路と会館待機を引継ぐ')
 if l.kind=='B':
  if not s.lights:out.append('明朝の灯り準備')
  if s.boxes:out.append(str(s.boxes)+'箱を店内保管')
  if s.noise>1 or (s.t<2 and 'quiet' not in s.sent):out.append('静かな帰路の案内。時計2の記録を確認')
 if l.kind=='C':
  if 'ferry' not in s.sent and s.t<4:out.append('渡し場の案内が未対応。残る便は'+('1/4' if s.t==0 else '4')+'刻')
  if s.bridge=='未確認':out.append('橋の開閉は未確認')
  elif s.bridge=='開通' and 'bridge' not in s.sent:out.append('確認済みの橋案内は後任へ')
  if home_count(s)<len(s.people):out.append(str(len(s.people)-home_count(s))+'人の帰路を引継ぐ')
 out.extend(pending_text(e) for e in s.pending)
 return tuple(out) or ('追加の帰路課題なし。放送記録を後任へ。',)
def canonical(o):return json.dumps(o,ensure_ascii=False,sort_keys=True,separators=(',',':'))
def digest(o):return hashlib.sha256(canonical(o).encode('utf-8')).hexdigest()
def replay(l,actions,variant=False):
 if len(actions)>512:raise ValueError('max 512 actions')
 t=initial(l);rows=[]
 for k,a in enumerate(actions):t=step(t,l,a,variant);rows.append({'tick':k,'action':a,'state':asdict(t.state),'sha256':digest(asdict(t.state))})
 return {'version':VERSION,'level':l.id,'preview_followup':variant,'events':rows,'final':asdict(t.state),'result_lines':result_lines(t.state,l),'unresolved':unresolved(t.state,l),'sha256':digest(asdict(t.state))}

# 教材用の入力・表示。モデル以外に原作との違いがあります。
STAGE = 5
INITIAL_CURSOR = 0
KEYS = ('UP','DOWN','Z','RETURN','X','R','1','2','3','4','B')
CARD_LABELS = {
 'bus':'BUS: TWO SEATS AT T2', 'bus_late':'BUS: DEPARTED',
 'umbrella':'HELPER: ONE PERSON AT T3', 'umbrella_late':'HELPER: NEXT MORNING',
 'song':'SONG: ONLY SLOT 0', 'song_late':'SONG: MISSED TONIGHT',
 'shelter':'SHELTER: A PLACE TO WAIT', 'thanks':'THANKS: WALK ARRANGED',
 'repair':'LIGHTS: WORK AT T2 (+1)', 'repair_late':'LIGHTS: MORNING WORK',
 'shop':'BOXES: PICKUP AT T2 (+2)', 'shop_late':'BOXES: STORE THEM',
 'quiet':'QUIET: NEXT TICK (-2)', 'lights_call':'CALL: LIGHTS PREPARED',
 'morning_call':'CALL: LIGHTS STILL NEEDED', 'storage_call':'CALL: STORE TWO BOXES',
 'quiet_call':'CALL: EARLY AND NOW', 'ferry':'FERRY: ONE AT T1 AND T4',
 'check':'CHECK: ANSWER IN THREE TICKS', 'bridge':'BRIDGE: CONFIRMED OPEN',
 'bridge_closed':'BRIDGE: CONFIRMED CLOSED', 'jingle':'JINGLE: TEXT SIGNAL / +1 TICK',
 'handoff':'HANDOFF: CLOSE AT CURRENT TICK'}

class App:
    def __init__(self):
        self.level = 0
        self.variant = False
        self.reset()
    @property
    def board(self):
        return LEVELS[self.level]
    @property
    def state(self):
        return self.timeline.state
    def reset(self):
        self.timeline = initial(self.board)
        self.cursor = INITIAL_CURSOR
        self.gated = False
        self.message = 'READING DOES NOT SPEND A SLOT'
    def ids(self):
        return visible(self.state,self.board,self.variant)
    def action(self,action):
        self.timeline = step(self.timeline,self.board,action,self.variant)
        self.cursor = min(self.cursor,len(self.ids())-1)
    def press(self,key):
        if key == 'R':
            self.reset()
            return
        if key in ('1','2','3','4') and int(key) <= len(LEVELS):
            self.level = int(key)-1
            self.reset()
            return
        if key == 'B' and STAGE == 5 and self.board.kind == 'A':
            self.variant = not self.variant
            self.reset()
            return
        if key == 'X' and STAGE == 5:
            self.action('undo')
            return
        if self.state.ended:
            return
        if key in ('UP','DOWN'):
            self.cursor = (self.cursor + (-1 if key=='UP' else 1)) % len(self.ids())
        elif key in ('Z','RETURN'):
            self.action(('read:' if key=='Z' else 'send:') + self.ids()[self.cursor])
    def control(self,pressed,held):
        # 合成テストとPyxelの両方がこの入口を使う。全部離すまで次を受けない。
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.message = 'RELEASE ALL KEYS / CHORD REJECTED'
            self.gated = True
            return
        if len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True
    def notice(self,c):
        s = self.state
        if s.ended:
            return ('CLOSED: PENDING IS NOT ACHIEVED', 'X UNDO (STAGE 5) / R NEW TRIAL')
        if not enabled(s,self.board,c):
            return ('CONDITIONAL SAMPLE ONLY', 'WAIT FOR ITS ACTUAL CALL')
        if c == 'check':
            return ('ANSWER DUE AT T' + str(s.t+3), 'A LATER BROADCAST IS STILL REQUIRED')
        if c == 'bridge':
            return ('CONFIRMED OPEN: HOME NEXT TICK', 'ONLY THIS BROADCAST RESERVES RETURN')
        if c == 'bridge_closed':
            return ('CONFIRMED CLOSED', 'NO BRIDGE RETURN IS RESERVED')
        if c == 'handoff':
            return ('CLOSE NOW / NO CLOCK ADVANCE', 'KEEP PENDING AND UNKNOWN ITEMS')
        if c == 'shelter':
            return ('CONFIRM A PLACE TO WAIT', 'HOME COUNT DOES NOT INCREASE')
        if c == 'ferry':
            return ('ARRIVE NEXT TICK, THEN DEPART', 'BOATS AT T1 / T4, ONE SEAT EACH')
        if c == 'bus':
            return ('ARRIVE NEXT TICK, THEN DEPART', 'BUS AT T2, TWO SEATS')
        if c == 'umbrella':
            return ('ONE WALK HOME AT T3', 'ONLY IF SOMEONE IS STILL WAITING')
        if c == 'quiet':
            return ('AT NEXT TICK: MAX(0, NOISE - 2)', 'AFTER REPAIR AND SHOP AT SAME TICK')
        return ('Z READ / ENTER BROADCAST', 'CHECK DEADLINES BEFORE SENDING')
    def draw(self,p):
        s=self.state
        p.cls(1)
        p.text(14,12,'STRANDED RADIO / STAGE '+str(STAGE)+' / NIGHT '+self.board.id,10)
        p.text(14,27,'FICTIONAL PEOPLE / SILENT TEXT SIGNALS',6)
        p.text(14,47,'TICK '+str(s.t)+' / 4    '+('CLOSED' if s.ended else 'ON AIR')+'    HISTORY '+str(len(self.timeline.past)),11)
        for i in range(4):
            p.rect(440+i*58,46,50,8,11 if i<s.t else 5)
        p.text(14,72,'INCOMING VOICES',10)
        ids=self.ids()
        start=max(0,self.cursor-6)
        for j,c in enumerate(ids[start:start+7]):
            y=88+j*33
            allowed=enabled(s,self.board,c)
            p.rectb(12,y,298,29,10 if start+j==self.cursor else 5)
            p.text(19,y+5,CARD_LABELS[c],7 if allowed else 13)
            label='READ' if c in s.read else 'UNREAD'
            if not allowed and not s.ended:label='CONDITIONAL SAMPLE'
            p.text(19,y+17,label,6)
        p.text(14,326,str(self.cursor+1)+' / '+str(len(ids))+'   B SAMPLES: '+str(self.variant).upper(),6)
        c=ids[min(self.cursor,len(ids)-1)]
        p.text(330,74,'SELECTED: '+c.upper(),10)
        for j,line in enumerate(self.notice(c)):p.text(330,93+j*14,line,7)
        p.text(330,130,'CONFIRMED CURRENT STATE',10)
        p.text(330,149,'SENT: '+(', '.join(s.sent) or 'NONE'),7)
        if STAGE == 1:
            p.text(330,173,'SONG SENT: '+str('song' in s.sent).upper(),11)
            p.text(330,192,'NO TRANSPORT MODEL IN THIS STAGE',6)
        else:
            if self.board.kind in ('A','C'):
                p.text(330,171,'HOME '+str(home_count(s))+' / WAIT '+str(len(s.people)-home_count(s)),11)
                p.text(330,189,'SHELTER CONFIRMED: '+str(s.shelter).upper(),7)
                labels={'P':'SQUARE','H':'HALL','B':'BUS','F':'FERRY','帰宅':'HOME'}
                for j,person in enumerate(s.people):
                    p.text(330+j*86,211,person.id+':'+labels[person.place],7)
                    p.text(330+j*86,224,'AT '+str(person.home_time) if person.place=='帰宅' else 'WAIT',6)
            if self.board.kind=='A':p.text(330,247,'WALK '+str(s.walkers)+' / SONG '+str(s.song).upper(),7)
            if self.board.kind=='B':
                p.text(330,171,'LIGHTS '+str(s.lights).upper()+' / BOXES '+str(s.boxes),11)
                p.text(330,189,'NOISE NOW '+str(s.noise),7)
                p.text(330,211,'EARLY QUIET AT T2: '+('NOT RECORDED' if s.early_quiet<0 else str(bool(s.early_quiet)).upper()),7)
            if self.board.kind=='C':
                p.text(330,247,'BRIDGE: '+{'未確認':'UNKNOWN','開通':'OPEN','閉鎖':'CLOSED'}[s.bridge],11)
                p.text(330,262,'BRIDGE BROADCAST: '+str('bridge' in s.sent).upper(),7)
            p.text(330,285,'PENDING / NOT ACHIEVED',10)
            for j,event in enumerate(s.pending):
                p.text(330,303+j*14,'T'+str(event.due)+' P'+str(event.priority)+' '+event.kind.upper(),7)
            if not s.pending:p.text(330,303,'NONE',6)
        p.line(14,351,702,351,5)
        if s.ended:
            p.text(14,365,'HANDOVER / CURRENT FACTS AND REMAINING WORK',10)
            if STAGE==1:
                p.text(14,384,'SONG: '+('INTRODUCED' if 'song' in s.sent else 'NOT INTRODUCED TONIGHT'),7)
            else:
                for j,line in enumerate(handover_lines(s,self.board)[:5]):p.text(14,384+j*15,line,7)
        else:
            p.text(14,365,'NO CLOCK ADVANCE WHILE READING OR THINKING',10)
            p.text(14,384,'ARRIVALS / WORK -> DEPARTURES / EARLY RECORD -> NEW CALLS -> END',6)
            p.text(14,405,'SLOTS ARE LIMITED. THERE IS NO HIDDEN ALL-HAPPY SCORE.',7)
        p.text(14,469,self.message,10)
        p.text(14,488,'UP/DOWN SELECT / Z READ / ENTER SEND / X UNDO (5) / R RESET',7)
        p.text(14,505,'1-'+str(len(LEVELS))+' NIGHT / B A-SAMPLES (5) / ESC QUIT / RELEASE EACH KEY',6)

def handover_lines(s,l):
    lines=[]
    if l.kind=='A':
        if not s.song:lines.append('SONG: OPPORTUNITY STILL IN SLOT 0' if s.t==0 else 'SONG: MISSED TONIGHT')
        if s.t<2 and 'bus' not in s.sent:lines.append('BUS ANNOUNCEMENT STILL NEEDED BY SLOT 1')
        if s.t<2 and 'umbrella' not in s.sent:lines.append('HELPER REQUEST STILL NEEDED BY SLOT 1')
    if l.kind=='B':
        if not s.lights:lines.append('MORNING LIGHTING WORK REMAINS')
        if s.boxes:lines.append(str(s.boxes)+' BOXES REMAIN FOR STORAGE')
        if s.noise>1 or s.t<2 and 'quiet' not in s.sent:lines.append('QUIET ROUTE / CHECK T2 RECORD')
    if l.kind=='C':
        if 'ferry' not in s.sent and s.t<4:lines.append('FERRY ANNOUNCEMENT STILL NEEDED')
        if s.bridge=='未確認':lines.append('BRIDGE OPEN/CLOSED IS UNKNOWN')
        elif s.bridge=='開通' and 'bridge' not in s.sent:lines.append('BRIDGE CONFIRMED OPEN, NOT BROADCAST')
    if s.people and home_count(s)<len(s.people):lines.append(str(len(s.people)-home_count(s))+' PEOPLE STILL NEED A ROUTE / WAITING PLACE')
    lines.extend('PENDING T'+str(e.due)+': '+e.kind.upper() for e in s.pending)
    return lines or ['NO ADDITIONAL ROUTE TASK / KEEP THE BROADCAST RECORD']

def demo_actions():
    return ('4', 'send:check', 'send:handoff')

def demo_keys():
    app=App()
    keys=[]
    for action in demo_actions():
        if action in ('1','2','3','4','X'):
            keys.append(action);app.press(action);continue
        verb,c=action.split(':',1)
        while app.ids()[app.cursor]!=c:
            keys.append('DOWN');app.press('DOWN')
        key='Z' if verb=='read' else 'RETURN'
        keys.append(key);app.press(key)
    return tuple(keys)

def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(720,530,title='Stranded Radio lesson '+str(STAGE),fps=30,display_scale=1,headless=headless)
    app=App()
    sequence=demo_keys() if demo else ()
    keys={k:getattr(p,'KEY_'+k) for k in KEYS}
    limit=max(12,2*len(sequence)+6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p);p.screen.save(screenshot,1)
            p.quit()
        if headless:
            index=p.frame_count//2
            pressed={sequence[index]} if demo and p.frame_count%2==0 and index<len(sequence) else set()
            app.control(pressed,pressed)
        else:
            app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)})
    p.run(update,lambda:app.draw(p))

if __name__=='__main__':
    import argparse
    from pathlib import Path
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke',action='store_true')
    parser.add_argument('--demo-checkpoint',action='store_true')
    parser.add_argument('--screenshot')
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode')
    if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode')
    # Pyxelの初期化でcwdが変わる場合にも、出力先は呼出元cwdに固定する。
    if args.screenshot:
        target=Path(args.screenshot).resolve()
        if target.suffix.lower()!='.png' or not target.parent.is_dir() or target.exists():
            parser.error('use a new .png in an existing directory')
        args.screenshot=str(target)
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
AFTER STEP 05

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

放送できるのは最大4枠です。読むだけでは、時計は変わりません。処理の順番は、予約→時計+1→同じ時刻の出来事を優先順に処理→便の出発と早い静けさの記録→着信→終了です。橋の答えは、確認を始めてから+3の時計で届きます。橋の案内には、別の枠が必要です。会館は、帰宅した人数を増やしません。handoff では、時計は変わりません。Undo は、状態をまるごと戻します。

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

比べる点5ステップの教材元の完成版
段階01は歌と4つの枠だけ、02は夜Aの予約、03は夜Bの優先順位、04は夜Cの答え、05はモデル全部・閉局・Undo・見本の比較。02から、State のまだ使わないフィールドも持たせておく。最初から、すべての状況とすべての機能がある。
画面と入力段階ごとに1ファイル。外の素材はいらない。英字(ASCII)で短くまとめて表示。複数のキーの同時押しは、すべて受け付けない。元のゲームのカードの本文は、02以降のコードに入っている。日本語の文字、原稿・影響・地図の画面、表紙と一時停止。Z+Enter は読むだけになる。
記録と省いたものS の記録、F12、P、M はない。コマンドライン(CLI)で確認用のPNGを出すだけ。人物はすべて作り話で、外への連絡はなく、音も使わない。遊んでいる間の記録、比較、ページの表示、音のない自動デモ。

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

  1. model.py の process(due・priority・出発・新しい着信)
  2. model.py の enabled・visible・preview(見せてよい情報の境目)
  3. model.py の transition(check と handoff)
  4. model.py の step(read と past)
  5. model.py の result_lines・unresolved(まだできていない予約)
  6. app.py の update(Z の優先と、キーを離すまで待つ仕組み release gate)、draw(原稿のページ)、export・save
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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