# 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 小さな博物館の夜・日本語教材 3/5。架空の離散来客モデル。""" from dataclasses import dataclass, asdict, replace STAGE = 3 WIDTH, HEIGHT = 800, 560 TITLE = 'PREVIEW A DETERMINISTIC CHOICE' 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 State: layout: tuple = PRESET tick: int = 0 def initial(): return State() 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 place(state, cell, item): """置く・戻す・移動・置換。成功した時だけ新しいStateを返す。""" if type(cell) is not int or not 0 <= cell < 6 or item not in ITEMS: return state, 'invalid' layout = list(state.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 state, 'four_only' layout[cell] = item return replace(state, layout=tuple(layout), tick=state.tick + 1), 'placed' 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.state = 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 def act(self, action): _, values = action.split(':') cell, item = values.split(',') self.state, self.reason = place(self.state, int(cell), item) 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 == '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, (), () 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, 'TAB: PREVIEW NEXT VISITOR', 10) p.text(454, 343, 'Preview does not spend budget or learn tags.', 7) p.text(454, 360, 'Partial designs can be previewed.', 6) p.text(454, 377, 'No opening / commit in this stage.', 6) p.rect(10, 421, 780, 69, 0) p.text(18, 431, 'EDITING DOES NOT OPEN THE MUSEUM. NO AUTOMATIC VISITOR MOVEMENT.', 6) p.text(18, 449, 'Read LESSON.md for Japanese explanations, exact predictions and exercises.', 7) p.text(12, 500, 'STATUS: '+self.reason, 11) p.text(12, 516, 'ARROWS STAND | SPACE ITEM | Z PLACE / REMOVE | R RESET', 7) p.text(12, 534, 'ONE KEY AT A TIME. RELEASE ALL KEYS BEFORE NEXT INPUT. ESC QUIT.', 6) CONTROL_KEYS = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'Z', 'R', 'TAB') DEMO_KEYS = ('TAB',) 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') 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') outputs = [Path(v).expanduser().resolve() for v in (args.screenshot, args.report) if v] 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() 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()