# 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)=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)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)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 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)=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