# 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。原作A、単独実行、ASCII表示。""" from dataclasses import dataclass, replace, asdict # 日本語は原作Aの表。displayは同じ記録のASCII表示で、判定には使わない。 CASE = {'title': '青いリボンの手帳', 'item': '青い手帳', 'found': (1, 1), 'owner': 0, 'people': (('サキ', (1, 0, 0), '花を写生しています。青いしおりを使います。'), ('レン', (None, 1, 1), '途中駅1で乗りました。地図の糸は赤色です。'), ('ミオ', (2, 2, 2), 'ずっと後両です。新聞を小さく折っています。')), 'evidence': (('発見メモ', 1, 1, '事実', '時刻1、中両の窓席で手帳を発見。'), ('リボンの写真', 1, 0, '事実', '時刻0、窓席の膝に青いリボン。物の全形は不明。'), ('花びらの鉛筆', 0, 2, '事実', 'サキの鉛筆には花びらの跡。花の写生に使う。'), ('レンの乗車券', 1, 2, '事実', '乗車は時刻1。時刻0にはまだ車内にいない。'), ('サキの話', 0, 2, '発言', 'サキ「途中駅1で、中両から前両へ移りました」'), ('レンの話', 1, 2, '発言', 'レン「旅行地図の目印には赤い糸を使います」'), ('ミオの話', 2, 2, '発言', 'ミオ「発車前から後両で、新聞を読んでいました」')), 'contradictions': ((), (1, 3), (1, 6)), 'refusal': ('', '時刻0には、私はまだ乗っていません。', '私は発車前から後両です。窓席は中両ですね。'), 'ending': ('サキ「この花の線、私の続きです。ありがとう」', '青いしおりを戻して、窓の向こうの花を描き始めた。'), 'display': {'people': ('SAKI', 'REN', 'MIO'), 'titles': ('FOUND NOTE', 'RIBBON PHOTO', 'FLOWER PENCIL', 'REN TICKET', 'SAKI SAYS', 'REN SAYS', 'MIO SAYS'), 'evidence': ('T1: blue notebook found at the middle-car window.', 'T0: blue ribbon on a lap at the middle-car window. The full object is ' 'not visible.', 'Saki has petal marks on her pencil. She sketches flowers.', 'Ren boarded at T1. Ren was not on the train at T0.', 'Saki: I moved from the middle car to the front at T1.', 'Ren: I mark my travel map with red thread.', 'Mio: I have read a newspaper in the rear car since T0.'), 'ending': ('Saki checks the flower drawing: This continues my sketch. Thank you.', 'She replaces her blue bookmark and draws flowers outside.'), 'refusal': ('', 'Ren: I was not on this train at T0.', 'Mio: I stayed in the rear. That window is in the middle car.')}} CARS = ('FRONT', 'MIDDLE', 'REAR') TIMES = ('T0 BEFORE', 'T1 STOP', 'T2 TERMINAL') def canonical_case(): return {key: value for key, value in CASE.items() if key != 'display'} def photo_people(time, car): return tuple(person[0] for person in CASE['people'] if person[1][time] == car) def photo_item(time, car): if (car, time) == CASE['found']: return '発見された ' + CASE['item'] if (car, time) == (1, 0): return '膝から青いリボン' return '' @dataclass(frozen=True) class State: car: int = 1 time: int = 2 known: tuple = () candidate: int = 0 reasons: tuple = () last: tuple = () def new(): return State(car=CASE['found'][0]) def visible(state): return tuple(i for i, record in enumerate(CASE['evidence']) if record[1:3] == (state.car, state.time)) def selected_conjunction(state): # 「AND」は選んだ既読記録の組。所有者を証明する関数ではない。 return tuple(CASE['evidence'][i][0] for i in state.reasons if i in state.known) def contradictions(state): return tuple(CASE['evidence'][i][0] for i in CASE['contradictions'][state.candidate] if i in state.known) def step(state, action): if not isinstance(action, str): raise ValueError('入力形式が不明です。') parts = action.split(':') kind = parts[0] if kind == 'car': return replace(state, car=(state.car + int(parts[1])) % 3, last=()) if kind == 'time': return replace(state, time=(state.time + 1) % 3, last=()) if kind == 'inspect': i = int(parts[1]) if i not in visible(state): return replace(state, last=('この時刻・車両では調べられません。',)) return replace(state, known=tuple(sorted(set(state.known + (i,)))), last=(CASE['evidence'][i][4],)) if kind == 'reason': i = int(parts[1]) if i not in state.known: return replace(state, last=('まだ読んでいない記録は根拠に選べません。',)) if i in state.reasons: return replace(state, reasons=tuple(j for j in state.reasons if j != i), last=()) if len(state.reasons) == 2: return replace(state, last=('根拠は最大2件。先に一つ外してください。',)) return replace(state, reasons=state.reasons + (i,), last=()) if kind == 'candidate': return replace(state, candidate=(state.candidate + int(parts[1])) % 3, last=()) raise ValueError('入力が不明です。') STAGE = 4 STAGE_TITLE = 'TEST AN IDEA' CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'Z', 'TAB', 'RETURN', 'X', 'R') REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'S': 'SPACE', 'Z': 'Z', 'B': 'TAB', 'E': 'RETURN', 'X': 'X', 'R': 'R'} class Controller: def __init__(self): self.previous = set() self.gate = False self.reset() def reset(self): self.state = new() self.phase = 'explore' self.choice = 0 self.notice = 'ready' def apply(self, action): old = self.state self.state = step(old, action) return action def feed(self, down): # 一度押したら全操作キーの解放を待つ。同時押しも状態を変えない。 down = set(down) & set(CONTROLS) fresh = down - self.previous self.previous = down if self.gate: if not down: self.gate = False return None if not fresh: return None self.gate = True if len(fresh) != 1: self.notice = 'ignored:multiple' return self.notice key = next(iter(fresh)) self.notice = self.handle(key) return self.notice def handle(self, key): if key == 'R': self.reset() return 'reset' if key == 'TAB': self.phase = 'explore' if self.phase == 'notebook' else 'notebook' self.choice = 0 return 'phase' if self.phase == 'submit': if key in ('LEFT', 'RIGHT'): return self.apply('candidate:' + str(-1 if key == 'LEFT' else 1)) if key in ('UP', 'DOWN'): self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(self.state.known)) return 'select:reason' if key == 'Z' and self.state.known: return self.apply('reason:' + str(self.state.known[self.choice % len(self.state.known)])) if key == 'SPACE': self.phase = 'explore' self.choice = 0 return 'back' return 'ignored:submit' if key == 'RETURN': self.phase = 'submit' self.choice = 0 return 'proposal' if self.phase == 'notebook': if key in ('UP', 'DOWN'): self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(self.state.known)) return 'select:note' return 'ignored:notebook' if key in ('LEFT', 'RIGHT'): self.choice = 0 return self.apply('car:' + str(-1 if key == 'LEFT' else 1)) if key == 'SPACE': self.choice = 0 return self.apply('time') ids = visible(self.state) if key in ('UP', 'DOWN'): self.choice = (self.choice + (-1 if key == 'UP' else 1)) % max(1, len(ids)) return 'select:evidence' if key == 'Z' and ids: return self.apply('inspect:' + str(ids[self.choice % len(ids)])) return 'ignored:key' def snapshot(self): data = {'state': asdict(self.state), 'phase': self.phase, 'choice': self.choice, 'notice': self.notice} data['visible'] = list(visible(self.state)) data['conjunction'] = list(selected_conjunction(self.state)) data['contradictions'] = list(contradictions(self.state)) return data def display_message(state): if not state.last: return '' for i, record in enumerate(CASE['evidence']): if state.last == (record[4],): return CASE['display']['evidence'][i] if state.last == CASE['ending']: return CASE['display']['ending'][0] for i, refusal in enumerate(CASE['refusal']): if state.last == (CASE['people'][i][0] + '「' + refusal + '」',): return CASE['display']['refusal'][i] errors = {'この時刻・車両では調べられません。': 'NOT VISIBLE HERE.', 'まだ読んでいない記録は根拠に選べません。': 'READ THIS RECORD BEFORE SELECTING IT.', '根拠は最大2件。先に一つ外してください。': 'TWO REASONS MAX. REMOVE ONE FIRST.'} return errors.get(state.last[0], 'RECORD UPDATED') def wrap(p, x, y, text, width=70, color=7): import textwrap for row, line in enumerate(textwrap.wrap(text, width=width)): p.text(x, y + row * 10, line, color) def draw_photos(p, state): # 時刻を切り替えても人を動かさない。同じ表を毎回参照するだけ。 for time, label in enumerate(TIMES): x = 12 + time * 208 p.rect(x, 45, 200, 164, 0) p.rectb(x, 45, 200, 164, 10 if time == state.time else 5) p.text(x + 8, 53, label, 10 if time == state.time else 6) for car, car_name in enumerate(CARS): y = 71 + car * 43 p.rect(x + 8, y, 184, 36, 1) p.rectb(x + 8, y, 184, 36, 11 if car == state.car else 5) p.text(x + 13, y + 4, car_name, 11 if car == state.car else 6) names = photo_people(time, car) for index, name in enumerate(names): person_id = next(i for i, row in enumerate(CASE['people']) if row[0] == name) px = x + 65 + index * 47 p.circ(px + 8, y + 11, 4, 15) p.rect(px + 4, y + 15, 9, 8, 8 + person_id) p.text(px - 1, y + 25, CASE['display']['people'][person_id], 7) item = photo_item(time, car) if item: p.rect(x + 166, y + 10, 12, 16, 12) p.text(x + 137, y + 28, 'RIBBON' if time == 0 else 'FOUND', 12) def draw_board(p, controller): state = controller.state p.cls(1) p.text(12, 9, 'LOST PROPERTY TRAIN / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10) p.text(12, 25, 'FICTIONAL CASE A. Photographs are records, not time travel.', 6) draw_photos(p, state) p.text(12, 218, 'VIEW: ' + CARS[state.car] + ' / ' + TIMES[state.time], 11) ids = visible(state) p.text(12, 234, 'RECORDS HERE / Z TO READ', 6) for row, i in enumerate(ids): selected = controller.phase == 'explore' and row == controller.choice prefix = '> ' if selected else ' ' read = '*' if i in state.known else '?' p.text(12, 248 + row * 12, prefix + read + ' E' + str(i) + ' ' + CASE['display']['titles'][i], 10 if selected else 7) if not ids: p.text(12, 248, 'NO RECORD AT THIS TIME / CAR', 5) p.text(328, 234, 'READ: ' + str(len(state.known)) + ' / 7', 6) if state.last: wrap(p, 328, 252, display_message(state), width=72, color=7) if controller.phase != 'explore': p.rect(8, 214, 624, 140, 0) p.text(16, 222, controller.phase.upper() + ' / ONLY READ RECORDS', 11) for row, i in enumerate(state.known): marker = '*' if i in state.reasons else ' ' selected = row == controller.choice kind = 'FACT' if CASE['evidence'][i][3] == '事実' else 'SAID' p.text(16, 240 + row * 13, ('> ' if selected else ' ') + marker + ' E' + str(i) + ' ' + kind + ' ' + CASE['display']['titles'][i], 10 if selected else 7) if not state.known: p.text(16, 244, 'EMPTY: READ A RECORD WITH Z FIRST', 6) elif controller.phase == 'notebook': i = state.known[controller.choice % len(state.known)] wrap(p, 312, 244, CASE['display']['evidence'][i], width=74) pair = ' AND '.join('E' + str(i) for i in state.reasons) p.text(16, 344, 'SELECTED: ' + (pair or '(NONE)'), 10) if controller.phase == 'submit': p.text(312, 240, 'OWNER IDEA: ' + CASE['display']['people'][state.candidate], 10) conflicts = contradictions(state) if conflicts: p.text(312, 258, 'CHECK KNOWN CONFLICTS:', 8) for n, title in enumerate(conflicts): i = next(i for i, row in enumerate(CASE['evidence']) if row[0] == title) p.text(312, 273 + n * 12, 'E' + str(i) + ' ' + CASE['display']['titles'][i], 8) else: p.text(312, 258, 'NO KNOWN CONFLICT IS NOT PROOF.', 6) p.text(312, 308, 'LEFT/RIGHT: PERSON Z: TOGGLE REASON', 6) p.text(312, 323, 'TWO SELECTED RECORDS FORM AN AND.', 6) p.rect(8, 360, 624, 30, 5) p.text(16, 368, 'LAST INPUT: ' + controller.notice, 7) if state.last: wrap(p, 260, 368, display_message(state), width=89, color=10) p.text(12, 402, 'ENTER: IDEA LEFT/RIGHT: PERSON Z: REASON SPACE: BACK', 6) p.text(12, 418, 'TAB: NOTEBOOK R: RESET ESC: QUIT NO RETURN IN STEP 4', 6) def main(): import argparse import json from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-replay', metavar='KEYS') parser.add_argument('--screenshot', type=Path) parser.add_argument('--report', type=Path) args = parser.parse_args() # 引数検査はPyxelのimport/initより前。モデル試験にPyxelは不要。 if args.headless_replay is None and (args.screenshot or args.report): parser.error('output paths require --headless-replay') if args.headless_replay is not None and any(c not in REPLAY for c in args.headless_replay): parser.error('replay keys must use: ' + ''.join(REPLAY)) if args.headless_replay is not None and len(args.headless_replay) > 1000: parser.error('at most 1000 replay keys') args.screenshot = args.screenshot.resolve() if args.screenshot else None args.report = args.report.resolve() if args.report else None import pyxel controller = Controller() frames = 0 events = [] replay = args.headless_replay limit = max(12, len(replay or '') * 2 + 2) mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS} pyxel.init(640, 440, title='095 Lost Property Train - Stage ' + str(STAGE), fps=30, display_scale=1, quit_key=pyxel.KEY_NONE, headless=replay is not None) def update(): nonlocal frames if pyxel.btnp(pyxel.KEY_ESCAPE): pyxel.quit() return if replay is None: down = {name for name, key in mapping.items() if pyxel.btn(key)} elif frames % 2 == 0 and frames // 2 < len(replay): down = {REPLAY[replay[frames // 2]]} else: down = set() result = controller.feed(down) if result is not None: events.append({'frame': frames, 'result': result, 'snapshot': controller.snapshot()}) frames += 1 def draw(): draw_board(pyxel, controller) if replay is not None and frames >= limit: if args.screenshot: args.screenshot.parent.mkdir(parents=True, exist_ok=True) pyxel.screenshot(str(args.screenshot), scale=1) if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps({'stage': STAGE, 'actions': replay, 'frames': frames, 'events': events, 'snapshot': controller.snapshot()}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') pyxel.quit() pyxel.run(update, draw) if __name__ == '__main__': main()