# 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. """068 小さな博物館の夜・日本語教材 5/5。架空の離散来客モデル。""" from dataclasses import dataclass, asdict, replace import hashlib import json STAGE = 5 WIDTH, HEIGHT = 800, 560 TITLE = 'UNDO / COMPARE / SAVE' ITEMS = ('T', 'M', 'B', 'N', 'V', 'L') NAMES = {'T': '種', 'M': '臼', 'B': '碗', 'N': '網', 'V': '舟', 'L': '鈴'} BASE = {'T': '育つ', 'M': '道具', 'B': '食', 'N': '海', 'V': '移動', 'L': '音'} PRESET = ('M', 'B', 'T', 'N', '', '') EN_ITEMS = {'T':'SEED', 'M':'MILL', 'B':'BOWL', 'N':'NET', 'V':'BOAT', 'L':'BELL'} EN_TAGS = {'育つ':'GROW', '道具':'TOOL', '食':'FOOD', '海':'SEA', '移動':'MOVE', '音':'SOUND', 'くらし':'LIFE', '協力':'COOP', '合図':'SIGNAL', '循環':'CYCLE'} PAIRS = (('T', 'M', '食', '種を臼でひいて食べ物へ'), ('M', 'B', 'くらし', '臼でひいた粉を碗によそう'), ('N', 'V', '協力', '舟と網で協力した漁'), ('V', 'L', '合図', '舟から鈴で知らせた合図'), ('T', 'B', '循環', '使い終わった碗で種を育てた工夫')) @dataclass(frozen=True) class Visitor: name: str interests: tuple goal: str budget: int @dataclass(frozen=True) class Level: id: str title: str visitors: tuple layout: tuple LEVEL = Level('068-C-g1', '同じ碗の二つの話', (Visitor('アサ', ('道具', 'くらし'), 'くらし', 5), Visitor('カナ', ('育つ', '循環'), '循環', 5), Visitor('ミオ', ('食', '海'), '食', 7)), PRESET) LEVELS = (LEVEL,) @dataclass(frozen=True) class Choice: cell: int item: str cost: int attraction: int new: tuple interest_new: tuple @dataclass(frozen=True) class Visit: visitor: int cell: int item: str cost: int attraction: int new: tuple budget_after: int learned: tuple @dataclass(frozen=True) class Result: visitor: int learned: tuple budget_left: int seen: tuple success: bool reason: str @dataclass(frozen=True) class State: layout: tuple mode: str = 'edit' visitor: int = 0 position: tuple = (-1, 0) budget: int = 0 seen: tuple = () learned: tuple = () visits: tuple = () results: tuple = () tick: int = 0 def initial(): return State(PRESET) def xy(cell): return (cell % 3, cell // 3) def adjacent(a, b, horizontal=False): x, y = xy(a) u, v = xy(b) return y == v and abs(x - u) == 1 if horizontal else abs(x - u) + abs(y - v) == 1 def contexts(layout, horizontal=False): tags = [set((BASE[it],)) if it else set() for it in layout] links = [] for a in range(6): for b in range(a + 1, 6): if not adjacent(a, b, horizontal): continue for x, y, tag, story in PAIRS: if set((layout[a], layout[b])) == {x, y}: tags[a].add(tag) tags[b].add(tag) links.append((a, b, tag, story)) return (tuple((tuple(sorted(t)) for t in tags)), tuple(links)) def choices(layout, p, position, budget, seen, learned, horizontal=False): tags, _ = contexts(layout, horizontal) options = [] for cell, it in enumerate(layout): if not it or it in seen: continue x, y = xy(cell) cost = abs(x - position[0]) + abs(y - position[1]) + 1 if cost > budget: continue new = tuple((t for t in tags[cell] if t not in learned)) interest = tuple((t for t in new if t in p.interests)) attraction = len(new) + len(interest) if attraction: options.append(Choice(cell, it, cost, attraction, new, interest)) return tuple(sorted(options, key=lambda c: (-c.attraction, c.cost, c.cell))) def next_choice(s, l, horizontal=False): if s.mode != 'run': return None options = choices(s.layout, l.visitors[s.visitor], s.position, s.budget, s.seen, s.learned, horizontal) return options[0] if options else None def advance(s, l, action, horizontal=False): op, sep, arg = action.partition(':') if op == 'place' and sep: if s.mode != 'edit': return (s, 'locked') bits = arg.split(',') if len(bits) != 2 or not bits[0].isdigit(): return (s, 'invalid') cell = int(bits[0]) item = bits[1] if not 0 <= cell < 6 or item not in ITEMS: return (s, 'invalid') layout = list(s.layout) if layout[cell] == item: layout[cell] = '' else: if item in layout: layout[layout.index(item)] = '' elif sum((bool(x) for x in layout)) == 4 and (not layout[cell]): return (s, 'four_only') layout[cell] = item return (replace(s, layout=tuple(layout), tick=s.tick + 1), 'placed') if action == 'open': if s.mode != 'edit': return (s, 'locked') if sum((bool(x) for x in s.layout)) != 4: return (s, 'need_four') return (State(s.layout, 'run', 0, (-1, 0), l.visitors[0].budget, tick=s.tick + 1), 'opened') if action == 'advance': if s.mode != 'run': return (s, 'not_open') choice = next_choice(s, l, horizontal) if choice: learned = tuple(sorted(set(s.learned) | set(choice.new))) budget = s.budget - choice.cost visit = Visit(s.visitor, choice.cell, choice.item, choice.cost, choice.attraction, choice.new, budget, learned) return (replace(s, position=xy(choice.cell), budget=budget, seen=s.seen + (choice.item,), learned=learned, visits=s.visits + (visit,), tick=s.tick + 1), 'viewed') p = l.visitors[s.visitor] possible = [i for i, it in enumerate(s.layout) if it and it not in s.seen] affordable = any((abs(xy(i)[0] - s.position[0]) + abs(xy(i)[1] - s.position[1]) + 1 <= s.budget for i in possible)) reason = '新しい理解が増えない' if affordable else '残り予算では届かない' if possible else '全ての展示を見た' result = Result(s.visitor, s.learned, s.budget, s.seen, p.goal in s.learned, reason) n = s.visitor + 1 return (replace(s, mode='done' if n == 3 else 'run', visitor=n, position=(-1, 0), budget=l.visitors[n].budget if n < 3 else 0, seen=(), learned=(), results=s.results + (result,), tick=s.tick + 1), 'closed' if n == 3 else 'next_visitor') if action == 'edit': if s.mode not in ('run', 'done'): return (s, 'not_open') return (State(s.layout, tick=s.tick + 1), 'editing') return (s, 'unknown') def forecast(s, l, horizontal=False): tags, links = contexts(s.layout, horizontal) first = tuple(((choices(s.layout, p, (-1, 0), p.budget, (), (), horizontal) or (None,))[0] for p in l.visitors)) return {'tags': tags, 'links': links, 'first': first, 'next': next_choice(s, l, horizontal)} def simulate(l, layout=None, horizontal=False): s = State(tuple(layout or l.layout)) s, _ = advance(s, l, 'open', horizontal) if s.mode != 'run': raise ValueError('four unique items required') while s.mode == 'run': s, _ = advance(s, l, 'advance', horizontal) return s def invariant(s, l, horizontal=False): its = tuple((x for x in s.layout if x)) assert len(its) <= 4 and len(set(its)) == len(its) and (set(its) <= set(ITEMS)) assert 0 <= s.visitor <= 3 and 0 <= s.budget and (s.visitor == len(s.results)) if s.mode == 'run': assert len(its) == 4 and s.budget <= l.visitors[s.visitor].budget tags, _ = contexts(s.layout, horizontal) for v in s.visits: assert set(v.new) <= set(tags[v.cell]) and v.item == s.layout[v.cell] return True VERSION = '068-g1' SCHEMA = 2 @dataclass(frozen=True) class Timeline: state: State history: tuple = () def step(t, l, action, horizontal=False): if action == 'undo': return (Timeline(t.history[-1], t.history[:-1]), 'undo') if t.history else (t, 'no_undo') s, why = advance(t.state, l, action, horizontal) if s == t.state: return (t, why) return (Timeline(s, t.history if action == 'advance' else t.history + (t.state,)), why) def digest(v): return hashlib.sha256(json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest() def record(l, actions, horizontal=False): t = Timeline(initial()) events = [] for n, a in enumerate(actions): t, why = step(t, l, a, horizontal) invariant(t.state, l) s = asdict(t.state) events.append({'index': n, 'input': a, 'reason': why, 'logical_tick': t.state.tick, 'state': s, 'sha256': digest(s)}) data = {'level': asdict(l), 'items': ITEMS, 'names': NAMES, 'base': BASE, 'pairs': PAIRS} return {'schema': SCHEMA, 'ruleset': VERSION, 'public_id': l.id, 'data': data, 'data_sha256': digest(data), 'settings': {'horizontal': horizontal}, 'inputs': list(actions), 'events': events, 'final': asdict(t.state), 'sha256': digest(asdict(t.state))} def replay(rec): if rec.get('schema') != SCHEMA or rec.get('ruleset') != VERSION: raise ValueError('unsupported version') l = next((l for l in LEVELS if l.id == rec.get('public_id')), None) if l is None: raise ValueError('unknown level') settings = rec.get('settings') if not isinstance(settings, dict) or set(settings) != {'horizontal'} or type(settings['horizontal']) is not bool: raise ValueError('invalid settings') expected = record(l, rec['inputs'], settings['horizontal']) if digest(expected) != digest(rec): raise ValueError('record mismatch') return expected def save_record(path, actions, horizontal=False): """保存は状態操作ではない。未開館・途中の記録も再現できる。""" from pathlib import Path target = Path(path).expanduser().resolve() data = record(LEVEL, actions, horizontal) replay(data) # 書く前に自己検証。成功・完成を要求しない。 target.parent.mkdir(parents=True, exist_ok=True) target.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') return target def words(tags): return ','.join(EN_TAGS[tag] for tag in tags) or '-' class App: def __init__(self, save_path=None): self.save_path = save_path self.horizontal = False self.reset() def reset(self): self.timeline = Timeline(initial()) self.cell = 0 self.item = 0 self.preview = 0 self.paused = False self.gated = False self.actions = [] self.reason = 'ready' self.last_save = None @property def state(self): return self.timeline.state def act(self, action): self.timeline, self.reason = step(self.timeline, LEVEL, action, self.horizontal) self.actions.append(action) def control(self, pressed, held): # 教材の入力: 1キーずつ全解放。原作の同時押し優先順位は再現しない。 if self.gated: if not pressed and not held: self.gated = False return if len(pressed | held) > 1: self.gated = True self.reason = 'release_all' return if len(pressed) != 1: return key = next(iter(pressed)) if key not in CONTROL_KEYS: return self.gated = True if key == 'R': self.reset() self.gated = True return if key == 'B': self.horizontal = not self.horizontal self.reset() self.gated = True return if key == 'S': if not self.save_path: self.reason = 'use_save_record_option' else: try: self.last_save = str(save_record(self.save_path, self.actions, self.horizontal)) self.reason = 'saved' except OSError: self.reason = 'save_failed' return if key == 'X': self.act('undo') return if key == 'P': self.paused = not self.paused return if self.paused: return if key == 'RETURN': self.act('open' if self.state.mode == 'edit' else 'edit') return if self.state.mode == 'run': if key == 'SPACE': self.act('advance') return if self.state.mode == 'done': return if key == 'TAB': self.preview = (self.preview + 1) % 3 return if key == 'LEFT': self.cell = (self.cell // 3) * 3 + (self.cell % 3 - 1) % 3 elif key == 'RIGHT': self.cell = (self.cell // 3) * 3 + (self.cell % 3 + 1) % 3 elif key in ('UP', 'DOWN'): self.cell = (self.cell + 3) % 6 elif key == 'SPACE': self.item = (self.item + 1) % 6 elif key == 'Z': self.act('place:' + str(self.cell) + ',' + ITEMS[self.item]) def draw(self, p): s = self.state p.cls(1) p.rect(0, 0, WIDTH, 43, 2) p.text(12, 9, 'NIGHT MUSEUM / STAGE ' + str(STAGE), 10) p.text(12, 26, TITLE, 7) p.text(490, 9, 'FICTIONAL DISCRETE MODEL / NOT REAL AUDIENCES', 6) p.text(490, 26, '4 ITEMS / 6 STANDS / 3 INDEPENDENT VISITORS', 6) p.text(12, 53, 'ENTRY (-1,0) -> STAND 1 ARROWS: SELECT STAND', 7) p.text(12, 66, 'SELECTED ITEM: ' + ITEMS[self.item] + ' / ' + EN_ITEMS[ITEMS[self.item]], 10) tags, links = contexts(s.layout, self.horizontal) for cell, it in enumerate(s.layout): x, y = 12 + (cell % 3) * 140, 86 + (cell // 3) * 96 p.rect(x, y, 132, 86, 2) p.rectb(x, y, 132, 86, 10 if cell == self.cell else 5) p.text(x+7, y+7, 'STAND ' + str(cell+1), 6) p.text(x+7, y+26, (it + ' ' + EN_ITEMS[it]) if it else 'EMPTY', 7) for row, tag in enumerate(tags[cell]): p.text(x+7, y+43+row*10, EN_TAGS[tag], 11) count = sum(bool(it) for it in s.layout) p.text(12, 280, 'EXHIBITS: ' + str(count) + '/4 LOGICAL TICK: ' + str(s.tick), 10) p.text(12, 302, 'CONTEXT: ' + ('HORIZONTAL ONLY' if self.horizontal else 'ORTHOGONAL / NO DIAGONALS'), 6) for i, (a, b, tag, story) in enumerate(PAIRS): active = any(link[2] == tag for link in links) p.text(12, 320+i*15, ('+ ' if active else '. ') + a + '+' + b + ' -> ' + EN_TAGS[tag], 11 if active else 5) who = self.preview position, budget, seen, learned = (-1, 0), LEVEL.visitors[who].budget, (), () if s.mode == 'run': who = s.visitor position, budget, seen, learned = s.position, s.budget, s.seen, s.learned visitor = LEVEL.visitors[who] options = choices(s.layout, visitor, position, budget, seen, learned, self.horizontal) p.text(454, 57, ('CURRENT' if hasattr(s, 'mode') and s.mode == 'run' else 'PREVIEW') + ' VISITOR: ' + ('ASA','KANA','MIO')[who], 10) p.text(454, 71, 'INTEREST: ' + words(visitor.interests) + ' GOAL: ' + EN_TAGS[visitor.goal], 7) p.text(454, 85, 'POSITION ' + str(position) + ' BUDGET ' + str(budget), 6) p.text(454, 99, 'KNOWN: ' + words(learned), 6) p.text(454, 120, 'ORDER: ATTRACTION DESC, COST ASC, STAND ASC', 10) p.text(454, 133, 'NEW INTEREST x2 + OTHER NEW x1; KNOWN = 0', 6) for i, choice in enumerate(options): y = 153+i*38 p.text(454, y, str(i+1)+'. STAND '+str(choice.cell+1)+' '+choice.item+' SCORE '+str(choice.attraction)+' COST '+str(choice.cost), 11 if i == 0 else 7) p.text(466, y+11, '2x: '+words(choice.interest_new), 11) p.text(466, y+22, '1x: '+words(tuple(t for t in choice.new if t not in choice.interest_new)), 6) if not options: p.text(454, 157, 'NO POSITIVE AFFORDABLE UNSEEN CHOICE', 9) p.text(454, 326, 'MODE: '+s.mode.upper()+(' / PAUSED' if self.paused else ''), 10) p.text(454, 341, 'SPACE commits ONE visit or ONE exit.' if s.mode == 'run' else 'ENTER opens a four-item design.' if s.mode == 'edit' else 'ENTER returns to editing.', 7) p.text(454, 356, 'Next visitor starts fresh at entry.', 6) for result in s.results: p.text(454, 372+result.visitor*12, ('ASA','KANA','MIO')[result.visitor]+': '+('GOAL YES' if result.success else 'GOAL NO')+' / '+words(result.learned), 11 if result.success else 9) if s.mode == 'done': score = sum(r.success for r in s.results) p.text(12, 404, 'GOALS REACHED: '+str(score)+'/3 '+('BASIC GOAL MET' if score >= 2 else 'TRY ANOTHER DESIGN'), 10) p.rect(10, 421, 780, 69, 0) p.text(18, 427, 'COMMITTED TRACE (visitor / item / old budget -> new / newly learned)', 6) for i, visit in enumerate(s.visits[-3:]): p.text(18, 443+i*13, ('ASA','KANA','MIO')[visit.visitor]+' / '+visit.item+' / '+str(visit.budget_after+visit.cost)+' -> '+str(visit.budget_after)+' / '+words(visit.new), 7) p.text(12, 500, 'STATUS: '+self.reason, 11) p.text(12, 516, 'ARROWS STAND | SPACE ITEM / COMMIT | Z PLACE | ENTER OPEN / EDIT | P PAUSE', 7) p.text(12, 534, 'X UNDO | B COMPARE+RESET | S SAVE TO --save-record PATH | R RESET | ESC QUIT', 6) CONTROL_KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'R', 'TAB', 'RETURN', 'P', 'X', 'B', 'S') DEMO_KEYS = ('RETURN', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE', 'SPACE') def main(argv=None): import argparse import json from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) mode = parser.add_mutually_exclusive_group() mode.add_argument('--headless-smoke', action='store_true') mode.add_argument('--demo-checkpoint', action='store_true') parser.add_argument('--screenshot') parser.add_argument('--report') parser.add_argument('--save-record', help='S writes this path; existing file is replaced') args = parser.parse_args(argv) headless = args.headless_smoke or args.demo_checkpoint if (args.screenshot or args.report) and not headless: parser.error('--screenshot/--report require a headless mode') if args.screenshot and Path(args.screenshot).suffix.lower() != '.png': parser.error('--screenshot must end in .png') if args.report and Path(args.report).suffix.lower() != '.json': parser.error('--report must end in .json') if args.save_record and Path(args.save_record).suffix.lower() != '.json': parser.error('--save-record must end in .json') outputs = [Path(v).expanduser().resolve() for v in (args.screenshot, args.report) if v] if args.save_record: outputs.append(Path(args.save_record).expanduser().resolve()) if len(set(outputs)) != len(outputs): parser.error('output paths must be distinct') if Path(__file__).resolve() in outputs: parser.error('cannot overwrite this source') # 検査が済むまでPyxelを読み込まない。importとモデル試験は標準ライブラリだけ。 import pyxel as p app = App(args.save_record) sequence = DEMO_KEYS if args.demo_checkpoint else () limit = max(12, 2*len(sequence)+4) keymap = {k: getattr(p, 'KEY_'+k) for k in CONTROL_KEYS} p.init(WIDTH, HEIGHT, title='068 Night Museum - Stage '+str(STAGE), fps=30, display_scale=1, headless=headless) finished = False def update(): 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 keymap.items() if p.btnp(v)}, {k for k,v in keymap.items() if p.btn(v)}) def draw(): nonlocal finished app.draw(p) if headless and p.frame_count >= limit and not finished: finished = True if args.screenshot: target = Path(args.screenshot).expanduser().resolve() target.parent.mkdir(parents=True, exist_ok=True) p.screenshot(str(target), scale=1) if args.report: target = Path(args.report).expanduser().resolve() target.parent.mkdir(parents=True, exist_ok=True) data = {'stage': STAGE, 'kind': 'actual Pyxel headless automated render', 'keys': sequence, 'state': asdict(app.state), 'reason': app.reason, 'horizontal': app.horizontal, 'os_input_tested': False, 'human_play_tested': False, 'audio_tested': False} target.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8') p.quit() p.run(update, draw) return app if __name__ == '__main__': main()