# 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。Python 3.11+ / Pyxel 2.9.9。""" COMMANDS = ('ask', 'safe', 'bridge', 'stop') from dataclasses import dataclass, replace, asdict from collections import deque @dataclass(frozen=True) class Level: edges: tuple = ('SJ', 'KU', 'UJ', 'JM', 'MN', 'NH', 'JT', 'TH') starts: tuple = ('S', 'K') hidden_start: str = 'S' opens: tuple = (4, 6) initial_mode: str = 'join' limit: int = 12 LEVEL = Level() @dataclass(frozen=True) class World: origin: str pos: str mode: str via: bool = False blocked: int = 0 def route(level, start, target, safe=False): """幅優先探索。同じ長さなら地点名順。安全路はJTを除く。""" if start == target: return (start,) queue = deque([(start,)]) seen = {start} while queue: path = queue.popleft() near = sorted({ edge[1] if edge[0] == path[-1] else edge[0] for edge in level.edges if path[-1] in edge and not (safe and set(edge) == {'J', 'T'}) }) for node in near: if node in seen: continue next_path = path + (node,) if node == target: return next_path queue.append(next_path) seen.add(node) return () def goal(world): if world.mode == 'stop': return '待機' if world.mode == 'join': return 'J' return 'T→H' if world.mode == 'bridge' and world.via else 'H' def instruct(world, command): if world.pos == 'H' or command == 'ask': return world return replace(world, mode=command, via=(command == 'bridge' and world.pos != 'T')) def walk(world, level, tick): if world.pos == 'H' or world.mode == 'stop': return world target = ('J' if world.mode == 'join' else 'T' if world.mode == 'bridge' and world.via else 'H') path = route(level, world.pos, target, safe=world.mode == 'safe') if len(path) < 2: return world if set((world.pos, path[1])) == {'J', 'T'} and tick not in level.opens: return replace(world, blocked=world.blocked + 1) return replace(world, pos=path[1], via=world.via and path[1] != 'T') @dataclass(frozen=True) class Pending: command: str sent: int received: int arrives: int snapshots: tuple @dataclass(frozen=True) class Reply: command: str sent: int received: int arrived: int sample: int phase: str pos: str goal: str def snapshot(world): return (world.origin, world.pos, goal(world)) @dataclass(frozen=True) class State: t: int actual: World candidates: tuple pending: object = None replies: tuple = () sent: int = 0 outcome: str = 'running' def initial(level=LEVEL): worlds = tuple(World(node, node, level.initial_mode) for node in level.starts) return State(0, next(w for w in worlds if w.origin == level.hidden_start), worlds) def advance(state, action, level=LEVEL): """受信→偶数刻の移動→返信→終了。拒否は同じStateを返す。""" if state.outcome != 'running': return state, 'ended' if action not in COMMANDS + ('wait',): return state, 'unknown' if action in COMMANDS and state.pending: return state, 'busy' tick = state.t + 1 actual, candidates = state.actual, state.candidates pending, replies, sent = state.pending, state.replies, state.sent if action in COMMANDS: actual = instruct(actual, action) candidates = tuple(instruct(w, action) for w in candidates) pending = Pending(action, state.t, tick, tick + 1, tuple(snapshot(w) for w in candidates)) sent += 1 if tick % 2 == 0: actual = walk(actual, level, tick) candidates = tuple(walk(w, level, tick) for w in candidates) if pending and pending.arrives == tick: pos = dest = '' if pending.command == 'ask': answer = next(row for row in pending.snapshots if row[0] == actual.origin) pos, dest = answer[1:] candidates = tuple(w for w in candidates if any( row[0] == w.origin and row[1:] == answer[1:] for row in pending.snapshots)) replies += (Reply(pending.command, pending.sent, pending.received, tick, pending.received, '移動前', pos, dest),) pending = None outcome = ('won' if actual.pos == 'H' else 'late' if tick >= level.limit else 'running') reason = 'rescued' if outcome == 'won' else 'deadline' if outcome == 'late' else 'advanced' return State(tick, actual, candidates, pending, replies, sent, outcome), reason def visible(state): """真の位置ではなく、同じ時計で進んだ候補だけを画面へ渡す。""" return tuple(sorted({world.pos for world in state.candidates})) @dataclass(frozen=True) class Timeline: state: State history: tuple = () def apply(timeline, action, level=LEVEL): if action == 'undo': if not timeline.history: return timeline, 'no_undo' return Timeline(timeline.history[-1], timeline.history[:-1]), 'undo' state, reason = advance(timeline.state, action, level) if state == timeline.state: return timeline, reason return Timeline(state, timeline.history + (timeline.state,)), reason XY = {'S': (36, 105), 'K': (36, 187), 'U': (94, 187), 'J': (159, 142), 'M': (159, 228), 'N': (215, 228), 'H': (274, 228), 'T': (274, 142)} KEY_TO_ACTION = {'SPACE': 'wait', '1': 'ask', '2': 'safe', '3': 'bridge', '4': 'stop', 'X': 'undo'} KEYS = tuple(KEY_TO_ACTION) + ('R',) DEMO_KEYS = ('1', 'SPACE', 'X') class App: def __init__(self): self.reset() def reset(self): self.timeline = Timeline(initial()) self.gated = False self.message = 'READY' @property def state(self): return self.timeline.state def control(self, pressed, held): # R is a fresh press; holding it does not reset every frame. if 'R' in pressed: self.reset() self.gated = True return if self.gated: if not pressed and not held: self.gated = False return if len(pressed | held) > 1: self.gated = True self.message = 'RELEASE ALL KEYS' return if len(pressed) != 1: return action = KEY_TO_ACTION.get(next(iter(pressed))) if action is None: return self.timeline, self.message = apply(self.timeline, action) self.gated = True def draw(self, p): state = self.state p.cls(1) p.text(12, 12, '061 DELAYED RADIO / STAGE 5', 10) p.text(12, 29, 'T '+str(state.t)+'/12 SENT '+str(state.sent) +' '+state.outcome.upper(), 11) p.text(12, 48, 'YELLOW RINGS = CURRENT CANDIDATES', 6) p.rectb(12, 72, 288, 178, 5) for edge in LEVEL.edges: x, y = XY[edge[0]] u, v = XY[edge[1]] bridge = set(edge) == {'J', 'T'} p.line(x, y, u, v, 10 if bridge else 5) if bridge: p.line(x, y+3, u, v+3, 10) locations = visible(state) for node, (x, y) in XY.items(): p.circ(x, y, 9, 3 if node == 'H' else 1) p.circb(x, y, 9, 11 if node == 'H' else 6) p.text(x-2, y-2, node, 7) if node in locations: p.circb(x, y, 14, 10) p.text(180, 126, 'BRIDGE JT', 10) p.text(310, 78, 'NEXT WALK: '+str(state.t+2-state.t%2), 7) p.text(310, 94, 'BRIDGE AT: 4,6', 10) p.text(310, 110, 'RINGS: '+','.join(locations), 6) p.text(310, 135, '1 ASK: WHERE?', 7) p.text(310, 150, '2 SAFE / 3 BRIDGE', 7) p.text(310, 165, '4 STOP', 7) p.text(310, 181, 'WORLDS: '+str(len(state.candidates)), 13) p.text(310, 196, 'SPACE WAIT', 6) p.text(310, 211, 'X UNDO', 6) p.text(310, 226, 'R RESET / ESC QUIT', 6) p.rect(12, 260, 456, 57, 2) pending = state.pending if pending: line = ('SENT '+str(pending.sent)+' / RECEIVED '+str(pending.received) +' / REPLY AT '+str(pending.arrives)) else: line = 'RADIO READY: SEND NOW' p.text(20, 269, line, 7) if state.replies: reply = state.replies[-1] if reply.command == 'ask': dest = {'待機': 'STOP', 'T→H': 'T>H'}.get(reply.goal, reply.goal) line = ('SAMPLE T'+str(reply.sample)+' BEFORE WALK: ' +reply.pos+' -> '+dest) else: line = reply.command.upper()+' RECEIVED T'+str(reply.received) p.text(20, 287, line, 11) p.text(20, 303, 'REPLY ARRIVED T'+str(reply.arrived)+' / THIS IS OLD DATA', 13) else: p.text(20, 287, 'NO REPLY YET. WAIT WITH SPACE.', 6) p.text(20, 303, 'A SECOND SEND IS REJECTED WHILE BUSY.', 13) p.text(12, 328, self.message.upper(), 11) def parse_args(argv=None): """すべてのCLI検証はPyxelをimportする前に終える。""" 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('--frames', type=int) parser.add_argument('--screenshot') parser.add_argument('--state-output') args = parser.parse_args(argv) if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output): parser.error('QA options require --headless-smoke') minimum = 2 * len(DEMO_KEYS) + 4 if args.demo_checkpoint else 4 if args.frames is None: args.frames = minimum if not minimum <= args.frames <= 600: parser.error('--frames must be between '+str(minimum)+' and 600') for name, suffix in (('screenshot', '.png'), ('state_output', '.json')): value = getattr(args, name) if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix): parser.error('--'+name.replace('_', '-')+' requires a '+suffix+' file path') return args def main(argv=None): args = parse_args(argv) from pathlib import Path import json import pyxel as p app = App() p.init(480, 352, title='061 Delayed Radio', fps=30, display_scale=2, headless=args.headless_smoke) keys = {key: getattr(p, 'KEY_'+key) for key in KEYS} finished = False def update(): if args.headless_smoke: index = p.frame_count // 2 pressed = ({DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) 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)}) def draw(): nonlocal finished app.draw(p) if args.headless_smoke and p.frame_count >= args.frames-1 and not finished: finished = True if args.screenshot: path = Path(args.screenshot).resolve() path.parent.mkdir(parents=True, exist_ok=True) p.screenshot(str(path), scale=2) if args.state_output: path = Path(args.state_output).resolve() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+'\n', encoding='utf-8') p.quit() p.run(update, draw) if __name__ == '__main__': main()