# 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。原作A固定、単独実行。架空の情報ルール。""" from dataclasses import dataclass, asdict, replace # 原作Aの公開候補。選ぶ順番でfactsやtruthは変わらない。 DOCUMENTS = ('map', 'photo', 'clock') NAMES = {'map': '地図', 'photo': '写真', 'clock': '時計'} PLACES = ('近い門', '近い机', '遠い門', '遠い机') SHAPES = ('四角', '丸', '三角') @dataclass(frozen=True) class Fact: place: int shape: int color: str time: int @dataclass(frozen=True) class Level: id: str title: str facts: tuple truth: int LEVEL = Level('A', '返された箱', ( Fact(0, 0, '赤', 0), Fact(1, 1, '赤', 0), Fact(2, 2, '青', 2), Fact(3, 2, '青', 3)), 1) def project(fact, document, detailed): if document == 'map': return ('ABCD'[fact.place] + '・' + PLACES[fact.place] if detailed else ('近い側 A/B' if fact.place < 2 else '遠い側 C/D')) if document == 'photo': return SHAPES[fact.shape] if detailed else fact.color if document == 'clock': return str(fact.time) + '刻' if detailed else ('早い 0/1刻' if fact.time < 2 else '遅い 2/3刻') raise ValueError('unknown document') @dataclass(frozen=True) class Answer: document: str detailed: bool value: str @dataclass(frozen=True) class State: answers: tuple = () selected: int = 15 result: str = '' feedback: str = '何を思い出してほしいか、最初の資料を選びましょう。' reports: tuple = () def compatible(answers): # 空のallはTrue。まだ聞かなければ4候補とも除外できない。 return sum(1 << i for i, fact in enumerate(LEVEL.facts) if all(project(fact, a.document, a.detailed) == a.value for a in answers)) def reason(state, candidate): for answer in state.answers: got = project(LEVEL.facts[candidate], answer.document, answer.detailed) if got != answer.value: return NAMES[answer.document] + 'の答え「' + answer.value + '」と候補「' + got + '」が違う' return '聞いた回答と矛盾なし。まだ除外できません。' def transition(state, action): if state.result: return state if action.startswith('show:'): document = action[5:] if document not in DOCUMENTS or document in [a.document for a in state.answers]: return state detailed = not state.answers answer = Answer(document, detailed, project(LEVEL.facts[LEVEL.truth], document, detailed)) return replace(state, answers=state.answers + (answer,), feedback=NAMES[document] + 'を提示: ' + answer.value + '。' + ('細部の回答。' if answer.detailed else '大分類の回答。')) if action.startswith('toggle:'): try: candidate = int(action[7:]) except ValueError: return state if 0 <= candidate < 4: return replace(state, selected=state.selected ^ (1 << candidate), feedback='候補に線を引きました。Enterで根拠と照合。') return state if action == 'assist': return replace(state, selected=compatible(state.answers), feedback='補助で回答と両立する候補を残しました。理由欄で確認できます。') if action == 'submit': if not state.answers: return replace(state, feedback='資料を一枚以上示してから報告してください。') mask = compatible(state.answers) count = mask.bit_count() ok = state.selected == mask if ok: message = '根拠に沿った特定。' if count == 1 else '根拠に沿った報告。この証言では' + str(count) + '件まで。' else: message = ('まだ否定できない候補があります。' if mask & ~state.selected else '回答と矛盾する候補が残っています。') return replace(state, result=('specific' if count == 1 else 'bounded') if ok else '', feedback=message, reports=state.reports + ((state.selected, mask, ok),)) return state @dataclass(frozen=True) class Timeline: state: State = State() past: tuple = () def step(timeline, action): if action == 'undo': return Timeline(timeline.past[-1], timeline.past[:-1]) if timeline.past else timeline if len(timeline.past) >= 128: return timeline new_state = transition(timeline.state, action) return Timeline(new_state, timeline.past + (timeline.state,)) if new_state != timeline.state else timeline def canonical(value): import json return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':')) def digest(value): import hashlib return hashlib.sha256(canonical(value).encode('utf-8')).hexdigest() def replay(actions): if len(actions) > 512: raise ValueError('bounded record: max 512 actions') timeline = Timeline() events = [] for tick, action in enumerate(actions): timeline = step(timeline, action) events.append({'tick': tick, 'action': action, 'state': asdict(timeline.state), 'sha256': digest(asdict(timeline.state))}) return {'version': '069-v1', 'level': LEVEL.id, 'precision': 'first_detail', 'events': events, 'final': asdict(timeline.state), 'sha256': digest(asdict(timeline.state))} STAGE = 5 STAGE_TITLE = 'RESTORE KNOWLEDGE WITH UNDO' CONTROLS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'A', 'RETURN', 'X', 'R') REPLAY = {'U': 'UP', 'D': 'DOWN', 'L': 'LEFT', 'T': 'RIGHT', 'S': 'SPACE', 'Z': 'Z', 'A': 'A', 'E': 'RETURN', 'X': 'X', 'R': 'R'} class Controller: def __init__(self): self.previous = set() self.gate = False self.reset() def reset(self): self.timeline = Timeline() self.document = 0 self.candidate = 0 self.notice = 'ready' @property def state(self): return self.timeline.state def apply(self, action): self.timeline = step(self.timeline, 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 self.notice = self.handle(next(iter(fresh))) return self.notice def handle(self, key): if key == 'R': self.reset() return 'reset' if key == 'X': return self.apply('undo') if self.state.result: return 'ignored:finished' if key in ('UP', 'DOWN'): self.document = (self.document + (-1 if key == 'UP' else 1)) % 3 return 'document' if key in ('LEFT', 'RIGHT'): self.candidate = (self.candidate + (-1 if key == 'LEFT' else 1)) % 4 return 'candidate' if key == 'Z': return self.apply('show:' + DOCUMENTS[self.document]) if key == 'SPACE': return self.apply('toggle:' + str(self.candidate)) if key == 'A': return self.apply('assist') if key == 'RETURN': return self.apply('submit') return 'ignored:key' def snapshot(self): data = {'state': asdict(self.state), 'document': self.document, 'candidate': self.candidate, 'notice': self.notice} data['compatible'] = compatible(self.state.answers) data['history_depth'] = len(self.timeline.past) return data # 日本語の本文/判定は上に保持。ウィンドウだけ標準フォントで読めるASCII訳。 DISPLAY = { 'A・近い門': 'A / NEAR GATE', 'B・近い机': 'B / NEAR DESK', 'C・遠い門': 'C / FAR GATE', 'D・遠い机': 'D / FAR DESK', '近い側 A/B': 'NEAR A/B', '遠い側 C/D': 'FAR C/D', '四角': 'SQUARE', '丸': 'CIRCLE', '三角': 'TRIANGLE', '赤': 'RED', '青': 'BLUE', '0刻': 'TIME 0', '1刻': 'TIME 1', '2刻': 'TIME 2', '3刻': 'TIME 3', '早い 0/1刻': 'EARLY 0/1', '遅い 2/3刻': 'LATE 2/3', } def wrap(p, x, y, text, width=96, color=7): import textwrap for row, line in enumerate(textwrap.wrap(text, width=width)): p.text(x, y + row * 11, line, color) def draw_shape(p, x, y, fact): color = 8 if fact.color == '赤' else 12 if fact.shape == 0: p.rect(x, y, 12, 12, color) elif fact.shape == 1: p.circ(x + 6, y + 6, 6, color) else: p.tri(x + 6, y, x, y + 12, x + 12, y + 12, color) def answer_reason_ascii(state, candidate): for answer in state.answers: got = project(LEVEL.facts[candidate], answer.document, answer.detailed) if got != answer.value: return answer.document.upper() + ': heard ' + DISPLAY[answer.value] + ', but H' + str(candidate + 1) + ' gives ' + DISPLAY[got] return 'No conflict with known answers. This candidate cannot yet be removed.' def draw_board(p, c): state = c.state p.cls(1) p.text(14, 12, 'SILENT WITNESS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10) p.text(14, 29, 'FICTIONAL RULE PUZZLE. Not a model of real witnesses or legal interviews.', 6) p.text(14, 44, 'CASE A / RETURNED BOX. The event stays fixed; only available detail changes.', 6) for i, document in enumerate(DOCUMENTS): x = 14 + i * 216 p.rect(x, 65, 207, 61, 2) p.rectb(x, 65, 207, 61, 10 if c.document == i else 5) p.text(x + 9, 74, document.upper(), 7) done = any(a.document == document for a in state.answers) p.text(x + 9, 92, 'ALREADY SHOWN' if done else 'Z: SHOW THIS DOCUMENT', 6 if done else 7) p.text(x + 9, 110, 'NEXT: ' + ('DETAIL' if not state.answers else 'CATEGORY'), 11) p.text(14, 141, 'PUBLIC POSSIBLE WORLDS / LEFT-RIGHT: SELECT H1-H4', 10) for i, fact in enumerate(LEVEL.facts): y = 158 + i * 36 p.rect(14, y, 632, 32, 0) p.rectb(14, y, 632, 32, 10 if c.candidate == i else 5) draw_shape(p, 35, y + 9, fact) p.text(61, y + 7, 'H' + str(i + 1) + ' ' + DISPLAY[project(fact, 'map', True)], 7) p.text(198, y + 7, DISPLAY[fact.color] + ' ' + DISPLAY[project(fact, 'photo', True)], 7) p.text(317, y + 7, DISPLAY[project(fact, 'clock', True)], 7) consistent = bool(compatible(state.answers) & (1 << i)) p.text(405, y + 7, 'CONSISTENT' if consistent else 'CONFLICT', 11 if consistent else 8) p.text(538, y + 7, '[KEEP]' if state.selected & (1 << i) else '[OUT]', 10) p.text(14, 311, 'RECEIVED ANSWERS / FIRST DETAIL, THEN CATEGORIES', 10) for i in range(3): y = 330 + i * 18 if i < len(state.answers): answer = state.answers[i] text = str(i + 1) + '. ' + answer.document.upper() + ' / ' + ('DETAIL' if answer.detailed else 'CATEGORY') + ' -> ' + DISPLAY[answer.value] p.text(22, y, text, 7) else: p.text(22, y, str(i + 1) + '. Not yet shown', 5) p.text(14, 392, 'H' + str(c.candidate + 1) + ' CHECK: ' + answer_reason_ascii(state, c.candidate), 6) if state.result: p.text(14, 416, 'REPORT ACCEPTED: ' + state.result.upper() + ' / ' + str(compatible(state.answers).bit_count()) + ' candidate(s)', 11) elif state.feedback == '資料を一枚以上示してから報告してください。': p.text(14, 416, 'SHOW AT LEAST ONE DOCUMENT BEFORE REPORTING.', 8) elif state.reports and not state.reports[-1][2] and state.feedback in ('まだ否定できない候補があります。', '回答と矛盾する候補が残っています。'): selected, mask, ok = state.reports[-1] p.text(14, 416, 'REPORT REJECTED: ' + ('a still-possible candidate was removed.' if mask & ~selected else 'a conflicting candidate remains.'), 8) else: p.text(14, 416, 'Report ALL consistent candidates. One or more can be correct.', 11) p.text(14, 448, 'UNDO HISTORY: ' + str(len(c.timeline.past)) + '/128. X restores the entire previous model state.', 6) p.rect(14, 479, 632, 23, 5) p.text(22, 487, 'LAST KEY: ' + c.notice, 7) p.text(14, 513, 'UP/DOWN: DOC Z: SHOW LEFT/RIGHT: CANDIDATE SPACE: KEEP A: ASSIST ENTER: REPORT X: UNDO', 6) p.text(14, 529, 'R: RESET SAME CASE ESC: QUIT TAKE YOUR TIME; NO COUNTDOWN', 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より先。モデル試験は標準ライブラリだけで動く。 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 c = Controller() frames = 0 events = [] sequence = args.headless_replay limit = max(12, len(sequence or '') * 2 + 2) mapping = {name: getattr(pyxel, 'KEY_' + name) for name in CONTROLS} pyxel.init(660, 550, title='069 Silent Witness - Stage ' + str(STAGE), fps=30, display_scale=1, quit_key=pyxel.KEY_NONE, headless=sequence is not None) def update(): nonlocal frames if pyxel.btnp(pyxel.KEY_ESCAPE): pyxel.quit() return if sequence is None: down = {name for name, key in mapping.items() if pyxel.btn(key)} elif frames % 2 == 0 and frames // 2 < len(sequence): down = {REPLAY[sequence[frames // 2]]} else: down = set() event = c.feed(down) if event is not None: events.append({'frame': frames, 'result': event, 'snapshot': c.snapshot()}) frames += 1 def draw(): draw_board(pyxel, c) if sequence 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': sequence, 'frames': frames, 'events': events, 'snapshot': c.snapshot()}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') pyxel.quit() pyxel.run(update, draw) if __name__ == '__main__': main()