# 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/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)) 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 @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 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 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) return s STAGE = 3 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.',)),) CANDIDATE_LABELS = (('Mina did not come', 'Mina moved the parcel at time 2', 'Sora moved the parcel to K', 'Destination still unknown [HOLD]'),) 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() 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 ('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) 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, 'LEFT/RIGHT CLAIM ENTER SUBMIT', 6) def demo_keys(): return ('DOWN', 'DOWN', 'DOWN', 'Z', 'SPACE', 'DOWN', 'Z', 'SPACE', 'RIGHT', 'RIGHT', 'RETURN') 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))