# 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. """すれ違い日記・教材2/5。Python 3.11+、単独実行用。""" from dataclasses import dataclass, asdict, replace from itertools import product, combinations import hashlib, json @dataclass(frozen=True) class Node: person: int time: int place: str topic: str fact: str kind: str = 'fact' claims: tuple = () opens: tuple = () feeling: str = '' @dataclass(frozen=True) class Candidate: text: str claims: tuple action: tuple provisional: bool = False @dataclass(frozen=True) class Level: id: str title: str people: tuple places: tuple nodes: tuple candidates: tuple worlds: tuple target: int interpretation: str scope: str def worlds(keys, domains, predicate=lambda w: True): return tuple((tuple(sorted(dict(zip(keys, values)).items())) for values in product(*domains) if predicate(dict(zip(keys, values))))) def claim(**kw): return tuple(((k, (v,)) for k, v in kw.items())) A_NODES = (Node(0, 0, 'B', '待ち合わせの約束', '0刻: Bでミナを待つと日記に書いた。', 'plan'), Node(0, 2, 'B', '空のベンチ', '2刻: Bに着くと、包みもミナも見えなかった。', 'observation'), Node(1, 1, 'B', '届けた時', '1刻: Bへ赤い包みを置いた。', claims=claim(delivered=1), opens=(3,)), Node(1, 1, 'R', 'その後の雨', 'その後: Rで雨宿りしていた。', claims=claim(shelter=1)), Node(2, 2, 'B', '包みを運んだ時', '2刻: Bから赤い包みを運んだ。行先は次の話題。', claims=claim(mover='sora'), opens=(5,)), Node(2, 2, 'K', '乾かした場所', '2刻: 赤い包みはKにあり、そこで乾かした。', claims=claim(dest='K'))) A_CAND = (Candidate('ミナは来なかった', claim(delivered=0), ('mina', 'absent', 'parcel', '-', 'B', 1)), Candidate('ミナが2刻に包みを運んだ', claim(mover='mina'), ('mina', 'move', 'parcel', 'B', '?', 2)), Candidate('ソラがKへ移した', claim(mover='sora', dest='K'), ('sora', 'move', 'parcel', 'B', 'K', 2)), Candidate('運び先はまだ不明', (), ('unknown', 'hold', 'parcel', 'B', '?', 2), True)) A_W = worlds(('delivered', 'mover', 'dest', 'shelter'), ((0, 1), ('none', 'mina', 'sora'), ('none', 'R', 'K'), (0, 1)), lambda w: w['delivered'] == 0 and w['mover'] == 'none' and (w['dest'] == 'none') or (w['delivered'] == 1 and w['mover'] != 'none' and (w['dest'] != 'none'))) LEVELS = (Level('A', '雨の包み', ('ハル', 'ミナ', 'ソラ'), (('B', 'ベンチ'), ('R', '軒'), ('K', '台所')), A_NODES, A_CAND, A_W, 2, '来なかったのかもしれない。', '2刻、Bからの包み移動'),) def fits(world, claims): w = dict(world) return all((w[k] in allowed for k, allowed in claims)) def remaining(level, evidence): return tuple((w for w in level.worlds if all((fits(w, n.claims) for i, n in enumerate(level.nodes) if evidence >> i & 1)))) def available(s): return tuple((i for i in range(6) if s.opened >> i & 1)) @dataclass(frozen=True) class State: read: int = 3 opened: int = 23 placed: int = 3 mirror: int = 0 questions: int = 0 feedback: str = '' tick: int = 0 def apply(s, l, a, variant=False): if a.startswith('read:'): i = int(a.split(':')[1]) if not 0 <= i < 6 or not s.opened >> i & 1 or s.read >> i & 1: return s n = l.nodes[i] opened = s.opened | sum((1 << j for j in n.opens)) return replace(s, read=s.read | 1 << i, opened=opened, questions=s.questions + 1, feedback=n.fact, tick=s.tick + 1) if a.startswith('place:'): i = int(a.split(':')[1]) if not 0 <= i < 6 or not s.read >> i & 1: return s n = l.nodes[i] if n.kind == 'self_report': return s placed = s.placed ^ 1 << i mirror = s.mirror ^ 1 << i if not variant and i >= 2 else s.mirror return replace(s, placed=placed, mirror=mirror, feedback='事実札を配置。これは行動の根拠で、内心の断定ではありません。', tick=s.tick + 1) return s STAGE = 2 FACT_LINES = ((('Time 0: Haru planned to wait for Mina at B.',), ('Time 2: Haru saw neither Mina nor the parcel at B.',), ('Time 1: Mina put the red parcel at B.',), ('Afterwards: Mina sheltered from rain at R.',), ('Time 2: Sora moved the red parcel from B.', 'The destination is a separate topic.'), ('Time 2: The red parcel was at K and dried there.',)),) class App: def __init__(self): self.level_index = 0 self.reset() @property def level(self): return LEVELS[self.level_index] def reset(self): self.state = State() self.node = 0 self.gated = False self.show_preview = False self.message = 'READ AND PLACE ARE TWO SEPARATE DECISIONS.' def snapshot(self): return {'level': self.level.id, 'state': asdict(self.state), 'worlds': len(remaining(self.level, self.state.placed))} def action(self, action): before = self.state self.state = apply(self.state, self.level, action) self.message = 'CHANGED: '+action.upper() if self.state != before else 'NO CHANGE: '+action.upper() def control(self, pressed, held): if self.gated: if not pressed and not held: self.gated = False return # Reset and level changes obey the same conflict gate as decisions. if len(pressed | held) > 1: self.gated = True self.message = 'CONFLICT: RELEASE ALL KEYS' return if len(pressed) != 1: return key = next(iter(pressed)) self.gated = True if key == 'R': self.reset(); self.gated = True return if key in ('UP','DOWN'): ids = available(self.state) self.node = ids[(ids.index(self.node)+(-1 if key == 'UP' else 1)) % len(ids)] elif key == 'Z': self.action('read:'+str(self.node)) elif key == 'SPACE': self.action('place:'+str(self.node)) def draw(self, p): s = self.state l = self.level p.cls(1) p.text(16, 12, '064 CROSSED DIARIES / STAGE '+str(STAGE)+' / CASE '+l.id, 10) p.text(16, 30, 'QUESTIONS '+str(s.questions)+' / 4 | TICK '+str(s.tick)+' | WORLDS '+str(len(remaining(l,s.placed))), 6) p.rect(12, 54, 190, 196, 2) p.rect(212, 54, 416, 196, 2) for i,n in enumerate(l.nodes): y = 69+i*28 opened = bool(s.opened >> i & 1) read = bool(s.read >> i & 1) used = bool(s.placed >> i & 1) if i == self.node: p.rect(16, y-5, 182, 22, 3) label = 'TOPIC '+str(i)+' '+('[USED]' if used else '[READ]' if read else '[ASK]' if opened else '[LOCKED]') p.text(22, y, label, 11 if used else 7 if opened else 5) n = l.nodes[self.node] p.text(224, 67, 'TOPIC '+str(self.node)+' / PERSON '+str(n.person)+' / TIME '+str(n.time), 10) if s.read >> self.node & 1: p.text(224, 89, 'PLACE '+n.place+' / '+n.kind.upper(), 6) for j,line in enumerate(FACT_LINES[self.level_index][self.node]): p.text(224, 117+j*18, line, 7) else: p.text(224, 89, 'PLACE AND TEXT: NOT YET DISCLOSED', 13) p.text(224, 117, 'Z ASKS ONLY THIS OPEN TOPIC.', 7) p.text(224, 178, 'READ != PLACED. ONLY PLACED CLAIMS FILTER WORLDS.', 6) p.text(224, 201, 'MIRRORED FACT SLOTS: '+str(s.mirror), 13) p.text(224, 222, "A GOAL OR FEELING IS NOT ANOTHER PERSON'S ACTION.", 13) p.text(16, 278, 'NO CONCLUSIONS YET. TEST WHICH FACTS ACTUALLY CONSTRAIN.', 10) p.text(16, 310, 'Initial plan and observation carry no world claims here.', 7) p.text(16, 334, 'Reading a topic can open another topic without placing it.', 7) p.text(16, 358, 'The missing parcel does not prove Mina never came.', 13) p.text(16, 434, self.message, 10) p.text(16, 458, 'UP/DOWN TOPIC Z READ SPACE PLACE/REMOVE R RESET ESC QUIT', 7) p.text(16, 478, 'READING HAS NO TIMER. R DOES NOT RELOAD CODE.', 6) def demo_keys(): return ('DOWN', 'DOWN', 'DOWN', 'Z', 'SPACE', 'DOWN', 'Z', 'SPACE') def main(headless=False, screenshot=None, demo=False, state_json=None): # Pyxel changes cwd during import/init. Resolve EVERY output first. from pathlib import Path if demo and not headless: raise ValueError('demo requires --headless-smoke') outputs = [] for value, suffix in ((screenshot, '.png'), (state_json, '.json')): if value is None: outputs.append(None) continue target = Path(value).resolve() if not headless or target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists(): raise ValueError('output needs headless mode, matching suffix, existing parent, and a new file') outputs.append(str(target)) screenshot, state_json = outputs import pyxel as p p.init(640, 500, title='Crossed diaries / lesson '+str(STAGE), fps=30, display_scale=1, headless=headless) app = App() sequence = demo_keys() if demo else () names = ('UP','DOWN','LEFT','RIGHT','Z','SPACE','RETURN','TAB','H','X','R','1','2','3') keys = {name: getattr(p, 'KEY_'+name) for name in names} limit = max(12, len(sequence)*2+6) def update(): if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit: if screenshot: app.draw(p) p.screen.save(screenshot, 1) if state_json: # x mode is also a last-moment overwrite guard. with open(state_json, 'x', encoding='utf-8') as stream: json.dump(app.snapshot(), stream, ensure_ascii=False, indent=2) p.quit() return if headless: index = p.frame_count//2 pressed = {sequence[index]} if p.frame_count%2 == 0 and index < len(sequence) else set() app.control(pressed, pressed) else: app.control({k for k,v in keys.items() if p.btnp(v)}, {k for k,v in keys.items() if p.btn(v)}) p.run(update, lambda: app.draw(p)) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--demo-checkpoint', action='store_true') parser.add_argument('--screenshot') parser.add_argument('--state-json') args = parser.parse_args() try: main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.state_json) except ValueError as error: parser.error(str(error))