# 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固定、単独実行。架空の情報ルール。""" 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 STAGE = 4 STAGE_TITLE = 'REPORT THE WHOLE SET' 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.state = State() self.document = 0 self.candidate = 0 self.notice = 'ready' def apply(self, action): self.state = transition(self.state, 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 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) 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, 'A copies the evidence set into KEEP. ENTER checks exact equality.', 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', 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()