# MIT License # # Copyright (c) 2026 Pyxel 100 contributors # # 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. # # The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL. # See assets/NOTO-LICENSE.txt. # 教材にNoto字形・フォントは含みません。Pyxel標準ASCIIと図形だけです。 """まちがい時計・教材5/5。原作Cの完全な候補集合パズル。""" from dataclasses import dataclass, replace from itertools import product import hashlib @dataclass(frozen=True) class Edge: source: str target: str delta: int # offset(target) - offset(source) @dataclass(frozen=True) class Level: title: str clocks: tuple[str, ...] edges: tuple[Edge, ...] margin: int display: tuple[str, ...] LEVEL = Level('まだ二つある', ('A', 'B', 'C', 'D'), (Edge('A', 'B', 1), Edge('B', 'C', 1), Edge('C', 'D', 1)), 2, ('A', 'B', 'C', 'D')) @dataclass(frozen=True) class Hypothesis: offsets: tuple[tuple[str,int], ...] outside: tuple[str, ...] zeros: tuple[str, ...] consistent: bool @property def valid(self): return self.consistent and not self.outside and len(self.zeros)==1 def validate(level): assert 3<=len(level.clocks)<=4 and len(set(level.clocks))==len(level.clocks) assert set(level.display)==set(level.clocks) and len(level.display)==len(level.clocks) assert isinstance(level.margin,int) and 0<=level.margin<=2 assert 2<=len(level.edges)<=3 assert all(e.source in level.clocks and e.target in level.clocks and e.source!=e.target and isinstance(e.delta,int) for e in level.edges) reached={level.clocks[0]} while True: new=reached | {e.target for e in level.edges if e.source in reached} | {e.source for e in level.edges if e.target in reached} if new==reached:break reached=new assert reached==set(level.clocks),'Every clock must be connected by visible evidence' assert assignments(level),'Inconsistent/empty cases are not accepted' def assignments(level): """Complete bounded oracle; no use of hypothesis propagation or expected answers.""" solutions=[] for values in product(range(-level.margin,level.margin+1),repeat=len(level.clocks)): if values.count(0)!=1:continue offset=dict(zip(level.clocks,values)) if all(offset[e.target]-offset[e.source]==e.delta for e in level.edges): solutions.append(tuple(zip(level.clocks,values))) return tuple(solutions) def answer(level): return frozenset(k for sol in assignments(level) for k,v in sol if v==0) def hypothesis(level,anchor): if anchor not in level.clocks:raise ValueError('Unknown clock') values={anchor:0} for _ in level.clocks: for e in level.edges: if e.source in values and e.target not in values:values[e.target]=values[e.source]+e.delta if e.target in values and e.source not in values:values[e.source]=values[e.target]-e.delta if set(values)!=set(level.clocks):raise ValueError('Disconnected evidence') consistent=all(values[e.target]-values[e.source]==e.delta for e in level.edges) return Hypothesis(tuple((k,values[k]) for k in level.clocks),tuple(k for k in level.clocks if abs(values[k])>level.margin),tuple(k for k in level.clocks if values[k]==0),consistent) @dataclass(frozen=True) class State: selected: frozenset[str]=frozenset() verdict: str='editing' # editing, incorrect, complete reason: str='ready' witness: str='' @dataclass(frozen=True) class Timeline: state: State=State() past: tuple[State,...]=() @dataclass(frozen=True) class Result: state: State accepted: bool event: str def transition(level,state,action): kind,clock=action if state.verdict=='complete':return Result(state,False,'terminal') if kind=='toggle' and clock in level.clocks: selected=state.selected ^ frozenset((clock,)) return Result(State(selected),True,'toggle') if kind=='submit' and clock is None: expected=answer(level) if state.selected==expected:return Result(replace(state,verdict='complete',reason='complete',witness=''),True,'complete') extras=state.selected-expected # Only one local, checkable counterexample. Do not reveal the entire answer. if extras: bad=next(k for k in level.clocks if k in extras) return Result(replace(state,verdict='incorrect',reason='excluded',witness=bad),True,'incorrect') return Result(replace(state,verdict='incorrect',reason='missing',witness=''),True,'incorrect') return Result(state,False,'invalid') def apply(level,timeline,action): result=transition(level,timeline.state,action) if not result.accepted:return timeline,result past=timeline.past+(timeline.state,) if result.event=='toggle' else timeline.past return Timeline(result.state,past),result def undo(timeline): if not timeline.past:return timeline # Undo candidate choice only; hypothetical inspections never create history. return Timeline(replace(timeline.past[-1],verdict='editing',reason='undo',witness=''),timeline.past[:-1]) def state_hash(state): return hashlib.sha256(repr((tuple(sorted(state.selected)),state.verdict,state.reason,state.witness)).encode('utf-8')).hexdigest() validate(LEVEL) STAGE = 5 STAGE_TITLE = 'CANDIDATE SET PUZZLE' CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'RETURN', 'X', 'Z', 'R') REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'S': 'SPACE', 'E': 'RETURN', 'X': 'X', 'Z': 'Z', 'R': 'R'} class Controller: """原作Cの論理状態と、画面用カーソル・仮置き・解放ゲートを分離する。""" def __init__(self): self.gate = False self.reset() def reset(self): self.timeline = Timeline() self.cursor = 0 self.anchor = None self.event = 'ready' @property def state(self): return self.timeline.state @property def focus(self): return LEVEL.display[self.cursor] def tick(self, pressed=(), held=()): pressed = set(pressed) & set(CONTROLS) held = set(held) & set(CONTROLS) if self.gate: if not held: self.gate = False return if len(pressed) != 1 or len(held) != 1 or pressed != held: return key = next(iter(pressed)) self.gate = True if key == 'R': self.reset() elif key in ('LEFT', 'UP', 'RIGHT', 'DOWN'): delta = 1 if key in ('RIGHT', 'DOWN') else -1 self.cursor = (self.cursor + delta) % len(LEVEL.display) self.event = 'cursor' elif key == 'X': self.anchor = None if self.anchor == self.focus else self.focus self.event = 'hypothesis' elif key == 'Z': self.timeline = undo(self.timeline) self.event = 'undo' else: action = ('toggle', self.focus) if key == 'SPACE' else ('submit', None) self.timeline, result = apply(LEVEL, self.timeline, action) self.event = result.event def snapshot(self): return {'selected': sorted(self.state.selected), 'verdict': self.state.verdict, 'reason': self.state.reason, 'witness': self.state.witness, 'focus': self.focus, 'anchor': self.anchor, 'event': self.event, 'history_depth': len(self.timeline.past), 'hash': state_hash(self.state)} def draw_board(pyxel, controller): state = controller.state pyxel.cls(1) pyxel.text(12, 10, 'WRONG CLOCKS / STEP 5 / CANDIDATE SET PUZZLE', 10) pyxel.text(12, 27, 'Original case C: select EVERY clock that can be correct.', 6) pyxel.rect(8, 42, 464, 36, 2) pyxel.text(16, 49, 'Integer offsets -2 .. +2. Exactly ONE zero in each assignment.', 7) pyxel.text(16, 64, 'Possible means zero in AT LEAST ONE consistent assignment.', 10) for index, clock in enumerate(LEVEL.display): x = 16 + 116 * index pyxel.rect(x, 89, 100, 100, 2) pyxel.circ(x + 50, 123, 25, 5) pyxel.circ(x + 50, 123, 22, 1) pyxel.text(x + 48, 121, clock, 10) selected = clock in state.selected pyxel.text(x + 24, 158, '[X] POSSIBLE' if selected else '[ ] UNCHECKED', 10 if selected else 13) if clock == controller.focus: pyxel.rectb(x - 2, 87, 104, 104, 7) for index, edge in enumerate(LEVEL.edges): pyxel.text(12, 205 + index * 16, f'{edge.target} - {edge.source} = +{edge.delta} ({edge.target} is ahead of {edge.source})', 7) pyxel.rect(8, 260, 464, 65, 0) if controller.anchor: h = hypothesis(LEVEL, controller.anchor) pyxel.text(16, 270, 'HYPOTHESIS: ' + controller.anchor + ' = 0 (does not change selection)', 6) pyxel.text(16, 286, ' '.join(f'{c}={v:+d}' for c, v in h.offsets), 7) result = ('OUT OF RANGE: ' + ','.join(h.outside)) if h.outside else 'VALID witness for this clock' if not h.consistent: result = 'INCONSISTENT differences' elif len(h.zeros) != 1: result = 'INVALID: zero count is not one' pyxel.text(16, 305, result, 11 if h.valid else 8) else: pyxel.text(16, 276, 'X: suppose the focused clock is correct (offset 0).', 6) pyxel.text(16, 298, 'Inspect evidence, then use SPACE to choose candidates.', 13) messages = {'ready': 'Select all possible clocks; then ENTER.', 'missing': 'Still missing at least one possible clock.', 'excluded': 'Excluded clock selected: ' + state.witness + '. Inspect it with X.', 'complete': 'COMPLETE: exact candidate set! Separate possible worlds.', 'undo': 'Last candidate toggle restored. Editing again.'} pyxel.text(12, 340, messages[state.reason], 10 if state.verdict == 'complete' else 7) pyxel.text(12, 358, 'SELECTED: ' + (' '.join(sorted(state.selected)) or '(none)') + ' HISTORY: ' + str(len(controller.timeline.past)), 11) pyxel.text(12, 378, 'ARROWS: focus SPACE: toggle ENTER: submit X: hypothesis', 7) pyxel.text(12, 396, 'Z: undo (also after success) R: reset ESC: quit', 6) def main(headless=False, actions='', screenshot=None, report=None): import json from pathlib import Path # 相対出力先は起動時の作業フォルダー基準。ソースの場所には依存しない。 screenshot = str(Path(screenshot).resolve()) if screenshot else None report = str(Path(report).resolve()) if report else None import pyxel controller = Controller() codes = {key: getattr(pyxel, 'KEY_' + key) for key in CONTROLS} frame = 0 events = [] last_frame = max(12, len(actions) * 2 + 2) def update(): nonlocal frame frame += 1 if headless: index = (frame - 1) // 2 if frame % 2 and index < len(actions): key = REPLAY[actions[index]] controller.tick((key,), (key,)) events.append({'input': actions[index], 'snapshot': controller.snapshot()}) else: controller.tick() else: controller.tick([key for key, code in codes.items() if pyxel.btnp(code)], [key for key, code in codes.items() if pyxel.btn(code)]) def draw(): draw_board(pyxel, controller) if headless and frame >= last_frame: if screenshot: pyxel.screenshot(screenshot, scale=1) if report: Path(report).write_text(json.dumps({ 'label': 'Actual Pyxel headless rendering; synthetic controller inputs; silent', 'stage': STAGE, 'frames': frame, 'actions': actions, 'events': events, 'snapshot': controller.snapshot(), }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') pyxel.quit() pyxel.init(480, 420, title='Wrong Clocks - Step ' + str(STAGE), fps=30, headless=headless) pyxel.run(update, draw) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--headless-replay', help='Synthetic inputs: ' + ', '.join(k + '=' + v for k, v in REPLAY.items())) parser.add_argument('--screenshot', help='PNG output; headless only') parser.add_argument('--report', help='UTF-8 JSON output; headless only') args = parser.parse_args() if args.headless_smoke and args.headless_replay is not None: parser.error('Choose smoke or replay') headless = args.headless_smoke or args.headless_replay is not None if not headless and (args.screenshot or args.report): parser.error('Outputs require headless mode') actions = args.headless_replay or '' if any(key not in REPLAY for key in actions): parser.error('Unsupported input for this stage') main(headless, actions, args.screenshot, args.report)