# 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. """一枚足りない写真: 第3段階。Python 3.11+ / Pyxel 2.9.9。""" from dataclasses import dataclass, replace, asdict MAX_STEP = 1 MOVE, RAIN, WET = 1, 2, 4 @dataclass(frozen=True) class Photo: pos: int roof: int wet: int @dataclass(frozen=True) class Level: id: str title: str rain: tuple known: tuple candidates: tuple LEVEL_A = Level("A", "一升ぶんの行き先", (0, 0, 0, 0), ((0, Photo(0, 0, 0)), (1, Photo(1, 0, 0)), (3, Photo(3, 0, 0))), tuple(Photo(x, 0, 0) for x in (1, 2, 3, 4))) LEVEL_B = Level("B", "屋根は最後に戻った", (0, 0, 1, 0), ((0, Photo(0, 1, 0)), (1, Photo(1, 1, 0)), (3, Photo(3, 1, 1))), (Photo(2, 1, 0), Photo(2, 0, 1), Photo(1, 0, 1), Photo(2, 1, 1))) LEVEL_C = Level("C", "戻ってきた車輪", (0, 0, 0, 0), ((0, Photo(1, 1, 0)), (1, Photo(1, 1, 0)), (3, Photo(1, 1, 0))), tuple(Photo(x, 1, 0) for x in (0, 1, 2, 3))) LEVELS = (LEVEL_A, LEVEL_B, LEVEL_C) def transition(a, b, rain, rules=7): if rules & MOVE and abs(a.pos - b.pos) > MAX_STEP: return False if rules & RAIN and not a.wet and b.wet != int(bool(rain and not b.roof)): return False if rules & WET and a.wet and not b.wet: return False return True def supports(level, candidate, rules=7): photos = dict(level.known) photos[2] = candidate return all(transition(photos[t - 1], photos[t], level.rain[t], rules) for t in range(1, 4)) def answer(level, rules=7): return sum(1 << i for i, p in enumerate(level.candidates) if supports(level, p, rules)) def witness(level, candidate): if not supports(level, candidate): return None return [asdict(dict(level.known).get(t, candidate)) for t in range(4)] def contradiction(level, candidate): photos = dict(level.known) photos[2] = candidate for t in range(1, 4): a, b = photos[t - 1], photos[t] if abs(a.pos - b.pos) > MAX_STEP: return f'{t-1}から{t}刻: 一間隔で二升以上は動けません。' if not a.wet and b.wet != int(bool(level.rain[t] and not b.roof)): return f'{t-1}から{t}刻: 乾いた荷物は雨と布の順に確かめよう。' if a.wet and not b.wet: return f'{t-1}から{t}刻: 濡れた荷物は途中で乾きません。' return '' @dataclass(frozen=True) class State: pair: tuple = (0, 3) selected: int = 0 reasons: int = 0 compared: tuple = () submissions: int = 0 result: str = '' feedback: str = 'この四枚のうち、可能な写真を全部残そう。' tick: int = 0 def apply(state, level, action): # 不正な外部入力は無変更。正規の意味入力だけを状態へ反映する。 if not isinstance(action, str): return state s = state if action.startswith('compare:'): try: _, a, b = action.split(':') a, b = int(a), int(b) except (ValueError, TypeError): return s if a not in (0, 1, 3) or b not in (0, 1, 3) or a == b: return s pair = tuple(sorted((a, b))) return replace(s, pair=pair, compared=s.compared + (pair,), feedback=f'{pair[0]}刻と{pair[1]}刻: {pair[1]-pair[0]}間隔、最大{(pair[1]-pair[0])*MAX_STEP}升。', tick=s.tick + 1) if action.startswith('candidate:'): try: i = int(action.split(':')[1]) except (ValueError, IndexError): return s if not 0 <= i < 4: return s return replace(s, selected=s.selected ^ (1 << i), result='', feedback='残す写真を選択中。Enterで集合を提出。', tick=s.tick + 1) if action == 'submit': correct = answer(level) bad = s.selected & ~correct if bad: result = 'contradiction' first_bad = (bad & -bad).bit_length() - 1 message = contradiction(level, level.candidates[first_bad]) elif s.selected != correct: result = 'narrow' message = 'まだ否定できない写真があります。可能なものを全部残そう。' else: result = 'won' message = f'候補集合の結論! {correct.bit_count()}枚が可能です。' return replace(s, submissions=s.submissions + 1, result=result, feedback=message, tick=s.tick + 1) return s STAGE = 3 DEFAULT_LEVEL = 1 FOCUS_COUNT = 2 class App: """キーから意味入力への変換。Pyxelなしでも操作テスト可能。""" def __init__(self): self.level_index = DEFAULT_LEVEL self.wait_release = False self.reset() @property def level(self): return LEVELS[self.level_index] def reset(self): self.state = State() self.focus = 0 self.cursor = 0 self.pick = None self.opacity = 2 self.message = 'READ THE KNOWN PHOTOS' def act(self, action): before = self.state self.state = apply(self.state, self.level, action) if self.state == before: self.message = 'NO CHANGE / CHECK LIMITS' return False if action.startswith('compare:'): self.message = 'COMPARISON SAVED / WORLD TIME DID NOT ADVANCE' elif action == 'submit': self.message = {'won': 'COMPLETE / ALL POSSIBLE CANDIDATES', 'contradiction': 'CONTRADICTION / AN IMPOSSIBLE PHOTO IS SELECTED', 'narrow': 'TOO NARROW / SOME POSSIBLE PHOTOS ARE MISSING', 'unsupported': 'SET MATCHES / REASONS ARE NOT SUFFICIENT'}[self.state.result] else: self.message = 'SELECTION UPDATED / ENTER TO SUBMIT' return True def control(self, down): down = set(down) & set(KEYS) if not down: self.wait_release = False return if self.wait_release: return self.wait_release = True if len(down) != 1: self.message = 'RELEASE ALL KEYS' return key = next(iter(down)) if key == 'R': self.reset() return if key in ('1', '2', '3'): self.level_index = int(key) - 1 self.reset() return if key == 'TAB': self.focus = (self.focus + 1) % FOCUS_COUNT self.cursor = 0 self.pick = None return count = (3, 4, 3)[self.focus] if key == 'LEFT': self.cursor = (self.cursor - 1) % count elif key == 'RIGHT': self.cursor = (self.cursor + 1) % count elif key == 'UP': self.opacity = min(2, self.opacity + 1) elif key == 'DOWN': self.opacity = max(0, self.opacity - 1) elif key == 'RETURN': self.act('submit') elif key == 'Z': if self.focus == 0: time = (0, 1, 3)[self.cursor] if self.pick is None: self.pick = time self.message = 'FIRST PHOTO PICKED / CHOOSE ANOTHER' elif self.pick != time: self.act(f'compare:{self.pick}:{time}') self.pick = None elif self.focus == 1: self.act(f'candidate:{self.cursor}') @staticmethod def photo(p, x, y, w, h, photo, rain=0, outline=False, background=True): if background: p.rect(x, y, w, h, 13) p.rect(x, y + h - 21, w, 21, 4) for i in range(5): p.text(x + 12 + i * ((w - 28) // 5), y + h - 14, str(i), 7) if rain: for dx in range(30, w - 4, 24): p.line(x + dx, y + 6, x + dx - 3, y + 14, 12) cx = x + 15 + photo.pos * ((w - 28) // 5) cy = y + h - 38 if outline: for dx in range(-11, 13, 4): p.pset(cx + dx, cy, 8) p.pset(cx + dx, cy + 9, 8) p.circb(cx - 7, cy + 13, 3, 8) p.circb(cx + 7, cy + 13, 3, 8) if photo.roof: for dx in range(-13, 15, 4): p.pset(cx + dx, cy - 16, 8) else: p.rect(cx - 11, cy, 23, 10, 9) p.circ(cx - 7, cy + 13, 3, 0) p.circ(cx + 7, cy + 13, 3, 0) p.rect(cx - 6, cy - 10, 12, 10, 12 if photo.wet else 10) if photo.roof: p.tri(cx - 14, cy - 13, cx, cy - 22, cx + 14, cy - 13, 3) p.line(cx - 11, cy - 13, cx - 11, cy, 3) p.line(cx + 11, cy - 13, cx + 11, cy, 3) def draw(self, p): # 描画は証拠と現在の操作状態のみを読む。判定はsubmit内で行う。 s, level = self.state, self.level known = dict(level.known) p.cls(1) p.text(12, 10, 'MISSING PHOTO / STAGE ' + str(STAGE), 10) p.text(350, 10, 'LEVEL ' + level.id + ' / MISSING TIME 2', 11) p.text(12, 27, 'POS = POSITION ROOF 0/1 WET 0/1 BLUE BOX = WET', 6) rain_times = ','.join(str(t) for t, rain in enumerate(level.rain) if rain) or 'NONE' p.text(350, 27, 'RAIN TIMES: ' + rain_times, 11) rule_text = 'MOVE <= %d / STOP OR RETURN ALLOWED' % MAX_STEP if STAGE >= 3: rule_text += ' / ROOF CHANGES / RAIN + NO ROOF WETS / WET STAYS' p.text(12, 37, rule_text, 6) for i, (time, photo) in enumerate(level.known): x = 12 + i * 208 self.photo(p, x, 48, 196, 106, photo, level.rain[time]) p.text(x + 6, 54, 'TIME ' + str(time) + (' RAIN' if level.rain[time] else ' CLEAR'), 1) p.text(x + 4, 163, 'POS %d ROOF %d WET %d' % (photo.pos, photo.roof, photo.wet), 7) if self.focus == 0 and self.cursor == i: p.rectb(x - 2, 46, 200, 110, 10) if self.pick == time: p.text(x + 132, 137, 'PICKED', 10) a, b = s.pair p.text(12, 190, 'COMPARE %d -> %d / %d INTERVALS' % (a, b, b - a), 10) self.photo(p, 12, 208, 294, 128, known[a], level.rain[a]) if self.opacity > 0: p.dither(self.opacity / 2) self.photo(p, 12, 208, 294, 128, known[b], level.rain[b]) p.dither(1.0) self.photo(p, 12, 208, 294, 128, known[a], outline=True, background=False) p.text(16, 342, 'OLD = RED DOTS NEW = SOLID', 6) p.text(16, 355, 'OPACITY %d%% / MAX MOVE %d' % (self.opacity * 50, (b - a) * MAX_STEP), 6) p.text(328, 190, 'TIME 2 CANDIDATES / KEEP ALL POSSIBLE', 10) for i, photo in enumerate(level.candidates): x, y = 328 + (i % 2) * 154, 208 + (i // 2) * 82 chosen = bool(s.selected & (1 << i)) p.rect(x, y, 146, 74, 2) p.rectb(x, y, 146, 74, 10 if self.focus == 1 and self.cursor == i else 11 if chosen else 5) p.text(x + 6, y + 6, 'PQRS'[i] + (' KEEP' if chosen else ' OUT'), 11 if chosen else 7) p.text(x + 6, y + 23, 'POS %d ROOF %d WET %d' % (photo.pos, photo.roof, photo.wet), 7) p.text(x + 6, y + 42, 'CANDIDATE / NOT TRUTH', 5) p.text(328, 380, 'RESULT: ' + (s.result.upper() or 'NOT SUBMITTED'), 11) p.text(12, 413, 'JUDGMENTS %d / COMPARISONS %d' % (s.tick, len(s.compared)), 6) p.text(12, 430, self.message, 11) p.text(12, 449, CONTROLS, 7) p.text(12, 467, 'R RESET / RELEASE ALL KEYS BETWEEN ACTIONS / ESC QUIT', 5) CONTROLS = 'TAB FOCUS LEFT/RIGHT SELECT Z TOGGLE ENTER SUBMIT' KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'TAB', 'RETURN', 'X', 'R', '1', '2', '3') DEMO_KEYS = ('TAB', 'RIGHT', 'Z', 'RETURN') def parse_args(argv=None): """QA引数は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') args = parser.parse_args(argv) if (args.demo_checkpoint or args.frames is not None or args.screenshot) and not args.headless_smoke: parser.error('QA options require --headless-smoke') minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8 if args.frames is None: args.frames = minimum if not minimum <= args.frames <= 600: parser.error('--frames must be between %d and 600' % minimum) if args.screenshot: path = Path(args.screenshot).expanduser().resolve() if path.suffix.lower() != '.png' or path.exists() or not path.parent.is_dir(): parser.error('screenshot must be a NEW .png file in an existing directory') args.screenshot = str(path) return args def main(argv=None): args = parse_args(argv) import json import pyxel as p app = App() p.init(640, 480, title='Missing Photo / lesson ' + str(STAGE), fps=30, display_scale=1, headless=args.headless_smoke) keys = {name: getattr(p, 'KEY_' + name) for name in KEYS} def update(): if args.headless_smoke and p.frame_count >= args.frames: if args.screenshot: p.screenshot(args.screenshot, scale=1) print(json.dumps({'state': asdict(app.state), 'level': app.level.id, 'focus': app.focus, 'cursor': app.cursor, 'history_length': len(app.timeline.history) if STAGE == 5 else 0}, ensure_ascii=True), flush=True) p.quit() return if args.headless_smoke: i = p.frame_count // 2 down = {DEMO_KEYS[i]} if args.demo_checkpoint and p.frame_count % 2 == 0 and i < len(DEMO_KEYS) else set() else: down = {name for name, code in keys.items() if p.btn(code)} app.control(down) p.run(update, lambda: app.draw(p)) if __name__ == '__main__': main()