# 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。Python 3.11+、単独実行用。""" from dataclasses import dataclass, asdict, replace from itertools import product, combinations import hashlib, json VERSION = '064-v1' MAX_HISTORY = 128 @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'))) B_NODES = (Node(0, 0, 'S', '集合の予定', '0刻: 2刻の鐘でSに集合、と手帳へ書いた。', 'plan'), Node(0, 2, 'S', '鳴らなかった鐘', '2刻: Sで待ったが、鐘は聞こえなかった。', 'observation'), Node(1, 1, 'T', '鐘の修理', '1刻: 修理のため、Tの鐘から縄を外した。', claims=claim(rope=0), opens=(3,)), Node(1, 2, 'L', '練習した場所', '2刻: Lで練習していた。', claims=claim(practice='L')), Node(2, 1, 'S', '集合札の行先', '1刻: 集合札をSからLへ移した。', claims=claim(sign='L'), opens=(5,)), Node(2, 3, 'S', '説明に戻った時', '3刻: Sへ戻り、ユイに説明した。', claims=claim(explain=3))) B_CAND = (Candidate('皆が練習をやめた', claim(practice='none'), ('all', 'stop', 'practice', 'S', '-', 2)), Candidate('集合場所がLへ移った', claim(sign='L', practice='L'), ('group', 'relocate', 'meeting', 'S', 'L', 2)), Candidate('ユイが時刻を間違えた', claim(wrongtime=1), ('yui', 'mistime', 'meeting', '-', 'S', 2)), Candidate('鐘まで全員待った', claim(practice='wait'), ('all', 'wait', 'bell', 'S', 'S', 2))) B_W = worlds(('sign', 'practice', 'rope', 'explain', 'wrongtime'), (('S', 'L'), ('none', 'wait', 'L'), (0, 1), (2, 3), (0,))) C_NODES = (Node(0, 0, 'D', '描く予定', '0刻: Dの灯りを描こう、とメモした。', 'plan'), Node(0, 2, 'D', '空になった机', '2刻: Dに戻ると、灯りも絵も見えなかった。', 'observation'), Node(1, 1, 'D', '灯りを動かした時', '1刻: 灯りをDからWへ動かした。', claims=claim(lamp='W'), opens=(3,)), Node(1, 1, 'W', '窓辺での本人の説明', '本人の説明: 窓辺の作業に使うためだった。', 'self_report', (), (), 'フミは、窓辺で作業したかったと話した。'), Node(2, 1, 'D', '絵を運んだ時', '1刻: 濡れた絵をDからSへ運んだ。', claims=claim(art_mover='ao', art='S'), opens=(5,)), Node(2, 2, 'S', 'その後の絵', '2刻にも、Sに絵が置いてあった。', claims=claim(art='S'))) C_CAND = (Candidate('フミが絵も捨てた', claim(art_mover='fumi', art='trash'), ('fumi', 'discard', 'art', 'D', 'trash', 1)), Candidate('アオが絵をSへ移した', claim(art_mover='ao', art='S'), ('ao', 'move', 'art', 'D', 'S', 1)), Candidate('ナギは絵を描いていない', claim(painted=0), ('nagi', 'absent', 'art', '-', 'D', 0)), Candidate('二人が絵を隠した', claim(intent='both_hide'), ('both', 'hide', 'art', 'D', '?', 1))) C_W = worlds(('lamp', 'art_mover', 'art', 'painted', 'intent'), (('W', 'S'), ('none', 'ao', 'fumi', 'both'), ('none', 'S', 'W', 'trash'), (0, 1), ('unknown', 'both_hide')), lambda w: w['art_mover'] == 'none' and w['art'] == 'none' or (w['art_mover'] != 'none' and w['art'] != 'none')) LEVELS = (Level('A', '雨の包み', ('ハル', 'ミナ', 'ソラ'), (('B', 'ベンチ'), ('R', '軒'), ('K', '台所')), A_NODES, A_CAND, A_W, 2, '来なかったのかもしれない。', '2刻、Bからの包み移動'), Level('B', '鳴らなかった合図', ('ユイ', 'レン', 'トワ'), (('T', '塔'), ('S', '広場'), ('L', '練習室')), B_NODES, B_CAND, B_W, 1, 'みんな、練習をやめたのかもしれない。', '2刻の集合の行先'), Level('C', '消えた灯りの絵', ('ナギ', 'フミ', 'アオ'), (('D', '作業机'), ('S', '乾燥棚'), ('W', '窓')), C_NODES, C_CAND, C_W, 1, 'フミが絵も片づけたのかもしれない。', '1刻、Dからの絵の移動')) 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 entails(level, evidence, candidate): options = remaining(level, evidence) return bool(options) and all((fits(w, level.candidates[candidate].claims) for w in options)) def counterevidence(l, evidence, candidate): ids = [i for i in range(6) if evidence >> i & 1] for size in range(1, len(ids) + 1): for subset in combinations(ids, size): mask = sum((1 << i for i in subset)) if not any((fits(w, l.candidates[candidate].claims) for w in remaining(l, mask))): return '反証: ' + l.nodes[subset[0]].fact return '初期の予定を確認: ' + l.nodes[0].fact 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 chosen: int = 0 questions: int = 0 result: str = '' ending: int = -1 feedback: str = '事実と、その時の解釈を分けて、空白を埋めよう。' tick: int = 0 @dataclass(frozen=True) class Timeline: state: State = State() history: tuple = () 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, result='', ending=-1, feedback='事実札を配置。これは行動の根拠で、内心の断定ではありません。', tick=s.tick + 1) if a.startswith('choose:'): i = int(a.split(':')[1]) if not 0 <= i < 4 or i == s.chosen: return s return replace(s, chosen=i, result='', ending=-1, feedback='この行を仮置き。Enterで根拠と照合します。', tick=s.tick + 1) if a == 'submit': c = l.candidates[s.chosen] options = remaining(l, s.placed) if c.provisional: result = 'held' msg = '保留として残しました。行先の根拠があれば、行動の一行へ進めます。' elif not any((fits(w, c.claims) for w in options)): result = 'contradiction' msg = counterevidence(l, s.placed, s.chosen) elif s.chosen != l.target: result = 'unsupported' msg = 'この行は、置いた事実だけでは確かめられていません。' elif not entails(l, s.placed, s.chosen): result = 'unsupported' msg = '行動は一致。' + missing(l, s.placed) else: result = 'won' msg = '根拠つきで日記がつながりました。終わり方はどちらでも完成。' return replace(s, result=result, feedback=msg, tick=s.tick + 1) if a.startswith('ending:'): i = int(a.split(':')[1]) if s.result != 'won' or i not in (0, 1) or s.ending == i: return s return replace(s, ending=i, feedback='事実だけを残しました。' if i == 0 else '事実の隣に、自分の「あの時はそう思った」を残しました。', tick=s.tick + 1) return s def missing(l, evidence): options = remaining(l, evidence) parts = [] for field, vals in l.candidates[l.target].claims: if any((dict(w)[field] not in vals for w in options)): parts.append({'mover': '誰が運んだか', 'dest': 'どこへ運ばれたか', 'sign': '集合札の行先', 'practice': '実際に練習した場所', 'art_mover': '絵を運んだ人', 'art': '絵の行先'}[field]) return '未確認: ' + '・'.join(parts) + '。' def step(t, l, a, variant=False): if a == 'undo': return Timeline(t.history[-1], t.history[:-1]) if t.history else t if len(t.history) >= MAX_HISTORY: return t try: n = apply(t.state, l, a, variant) except (ValueError, TypeError, IndexError): return t return t if n == t.state else Timeline(n, t.history + (t.state,)) def digest(obj): return hashlib.sha256(json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest() def replay(l, actions, variant=False): t = Timeline() rows = [] for a in actions: t = step(t, l, a, variant) rows.append({'action': a, 'tick': t.state.tick, 'state_sha256': digest(asdict(t.state))}) body = {'schema': 1, 'public_id': VERSION + '-' + l.id, 'level': asdict(l), 'settings': {'mirror_diary': not variant}, 'inputs': rows, 'terminal': asdict(t.state)} return dict(body, sha256=digest(body)) STAGE = 5 # ASCII translations are UI-only. The original Japanese data stays in LEVELS. 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.',)), (('Time 0: Yui planned to meet at S at the time-2 bell.',), ('Time 2: Yui waited at S but heard no bell.',), ('Time 1: Ren removed the rope from the bell at T.',), ('Time 2: Ren was practising at L.',), ('Time 1: Towa moved the meeting sign from S to L.',), ('Time 3: Towa returned to S and explained to Yui.',)), (('Time 0: Nagi planned to draw the lamp at D.',), ('Time 2: Nagi saw neither lamp nor art at D.',), ('Time 1: Fumi moved the lamp from D to W.',), ('Fumi said: I wanted to work by the window.', "SELF-REPORT: not evidence of another person's act."), ('Time 1: Ao moved the wet art from D to S.',), ('Time 2: The art was still on S.',)) ) CANDIDATE_LABELS = ( ('Mina did not come', 'Mina moved the parcel at time 2', 'Sora moved the parcel to K', 'Destination still unknown [HOLD]'), ('Everyone stopped practising', 'Meeting moved to L', 'Yui mistook the time', 'Everyone waited for the bell'), ('Fumi also threw away the art', 'Ao moved the art to S', 'Nagi never painted the art', 'Both people hid the art') ) def preview(level, evidence, candidate): """Predict logical status from PLACED claims only, never from unopened facts.""" options = remaining(level, evidence) matching = sum(fits(w, level.candidates[candidate].claims) for w in options) return (len(options), matching, bool(options) and matching == len(options)) class App: def __init__(self): self.level_index = 0 self.reset() @property def level(self): return LEVELS[self.level_index] @property def state(self): return self.timeline.state def reset(self): self.timeline = Timeline() 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.timeline = step(self.timeline, self.level, action) self.message = 'CHANGED: '+action.upper() if self.state != before else 'NO CHANGE: '+action.upper() if action == 'submit': self.message = 'RESULT: '+self.state.result.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 ('1','2','3'): self.level_index = int(key)-1 self.reset(); self.gated = True return if key == 'H': self.show_preview = not self.show_preview return if key == 'X': self.action('undo') if self.node not in available(self.state): self.node = 0 return if key == 'TAB' and self.state.result == 'won': self.action('ending:'+str(0 if self.state.ending == -1 else 1-self.state.ending)) 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)) elif key in ('LEFT','RIGHT'): self.action('choose:'+str((self.state.chosen+(-1 if key == 'LEFT' else 1))%4)) elif key == 'RETURN': self.action('submit') 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) for i,label in enumerate(CANDIDATE_LABELS[self.level_index]): y = 266+i*25 p.rect(12, y-4, 616, 21, 3 if s.chosen == i else 2) p.text(22, y, ('> ' if s.chosen == i else ' ')+str(i)+': '+label, 10 if s.chosen == i else 7) p.text(16, 372, 'RESULT: '+(s.result.upper() or 'NOT SUBMITTED'), 11) if self.show_preview: count, matching, entailed = preview(l, s.placed, s.chosen) p.text(250, 372, 'KNOWN-ONLY: '+str(matching)+' / '+str(count)+' MATCH', 6) p.text(250, 388, 'ENTAILED' if entailed else 'NOT ENTAILED', 6) if s.result == 'won': ending = ('CHOOSE WITH TAB' if s.ending == -1 else 'FACTS ONLY' if s.ending == 0 else 'FACTS + MY FORMER FEELING') p.text(16, 410, 'ENDING: '+ending+' / BOTH ARE COMPLETE', 11) else: p.text(16, 410, 'UNDO HISTORY: '+str(len(self.timeline.history))+' / 128', 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, 'L/R CLAIM ENTER SUBMIT 1/2/3 CASE H PREVIEW X UNDO TAB END', 6) def demo_keys(): return ('DOWN', 'DOWN', 'DOWN', 'Z', 'SPACE', 'DOWN', 'Z', 'SPACE', 'RIGHT', 'RIGHT', 'RETURN', 'H', 'TAB', 'TAB') 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))