# MIT License # # Copyright (c) 2026 Pyxel 100 contributors # # 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. # # The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL. # See assets/NOTO-LICENSE.txt. """ねずみ会議・教材4/5。単独実行できる段階別プログラム。""" from dataclasses import dataclass, replace, asdict from itertools import permutations from functools import lru_cache import hashlib, json NAMES = 'ABC' ITEMS = ('チーズ', 'りんご', '木の実') WORLDS = tuple(permutations(range(3))) @dataclass(frozen=True) class Level: title: str truth: tuple weaknesses: tuple visible: tuple initial_questions: tuple tickets: int goal: int LEVELS = (Level('正しく見える目', (0, 1, 2), (0, 2, 1), ((1, 2), (0, 2), (0, 1)), ((0, 2),), 1, 1), Level('同じ返事を重ねない', (1, 0, 2), (0, 0, 2), ((1, 2), (0, 2), (0, 1)), ((0, 1),), 2, 1), Level('二匹が残る答え', (0, 1, 2), (0, 0, 0), ((1, 2), (0, 2), (0, 1)), (), 2, 2)) @dataclass(frozen=True) class Observation: witness: int target: int item: int @dataclass(frozen=True) class Card: witness: int target: int answer: int @dataclass(frozen=True) class State: cards: tuple tickets: int won: bool = False def perceive(item, weakness): return (item + 1) % 3 if item == weakness else item def observe(level, witness, target): if witness == target or target not in level.visible[witness]: return None return Observation(witness, target, level.truth[target]) def testimony(observation, weakness): """A witness receives only one observed item, not the full hidden assignment.""" return Card(observation.witness, observation.target, perceive(observation.item, weakness)) def answer(level, w, t): o = observe(level, w, t) return None if o is None else testimony(o, level.weaknesses[w]) def initial(level): return State(tuple((answer(level, *q) for q in level.initial_questions)), level.tickets) def hypotheses(level, cards): return tuple((world for world in WORLDS if all((perceive(world[c.target], level.weaknesses[c.witness]) == c.answer for c in cards)))) def cheese_candidates(worlds): return tuple(sorted(set((w.index(0) for w in worlds)))) def candidates(level, state): return cheese_candidates(hypotheses(level, state.cards)) def legal_questions(level): return tuple(((i, j) for i in range(3) for j in level.visible[i] if i != j)) def ask(level, state, w, t): if state.won: return (state, 'complete') if (w, t) not in legal_questions(level): return (state, 'unseen') if any(((c.witness, c.target) == (w, t) for c in state.cards)): return (state, 'repeat') if state.tickets == 0: return (state, 'empty') return (replace(state, cards=state.cards + (answer(level, w, t),), tickets=state.tickets - 1), 'asked') def submit(level, state, selected): selected = tuple(sorted(set(selected))) if selected != candidates(level, state): return (state, 'mismatch') if len(selected) > level.goal: return (state, 'wide') return (replace(state, won=True), 'complete') def bounded_oracle(level, state): """Exact information-set search. No access to truth/actual next answer. Returns minimum worst-case questions within tickets, else None; max 6 legal questions and 6 worlds, decreasing budget guarantee termination. """ questions = legal_questions(level) seen = tuple(((c.witness, c.target) for c in state.cards)) @lru_cache(None) def search(worlds, used, budget): if len(cheese_candidates(worlds)) <= level.goal: return (0, None) if budget <= 0: return (None, None) best = (None, None) for q in questions: if q in used: continue groups = {} for world in worlds: groups.setdefault(perceive(world[q[1]], level.weaknesses[q[0]]), []).append(world) costs = [search(tuple(ws), tuple(sorted(used + (q,))), budget - 1)[0] for ws in groups.values()] if all((c is not None for c in costs)): cost = 1 + max(costs) if best[0] is None or cost < best[0]: best = (cost, q) return best return search(hypotheses(level, state.cards), tuple(sorted(seen)), state.tickets) STAGE = 4 LABELS = ("CHEESE", "APPLE", "NUT") MESSAGES = {'ready':'READY', 'asked':'NEW TESTIMONY RECORDED', 'repeat':'SAME QUESTION: NO TICKET SPENT', 'unseen':'THIS TARGET IS NOT VISIBLE', 'empty':'NO TICKETS LEFT', 'undo':'RESTORED ONE STATE', 'no_undo':'NO HISTORY', 'mismatch':'SELECTION != EVIDENCE CANDIDATES', 'wide':'CORRECT SET, BUT TOO MANY CANDIDATES', 'complete':'EVIDENCE-SUPPORTED CONCLUSION', 'choose':'LEFT/RIGHT: CURSOR Z: TOGGLE SPACE: SUBMIT', 'invalid':'RELEASE ALL KEYS', 'advice':'PLAN USES EVIDENCE, NOT HIDDEN TRUTH'} FOOTER1 = 'SPACE: CONCLUSION TAB: QUESTIONS H: CANDIDATE AID' FOOTER2 = '1/2/3: MEETING O: INFORMATION-SET PLAN' 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 = initial(self.level) self.witness, self.target = 0, 1 self.conclusion = False self.selected = set() self.cursor = 0 self.aid = True self.advice = None self.reason = 'ready' self.gated = False def commit(self, action): self.state, self.reason = ask(self.level, self.state, *action[1:]) if action[0] == 'ask' else submit(self.level, self.state, action[1]) self.advice = None def control(self, pressed, held): # R has explicit priority, including R+Z. It resets, never reloads code. if 'R' in pressed: self.reset(); self.gated = True; return if self.gated: if not pressed and not held: self.gated = False return if len(pressed | held) > 1: self.gated = True; self.reason = 'invalid'; return if len(pressed) != 1: return key = next(iter(pressed)); self.gated = True if key in ('1', '2', '3'): self.level_index = int(key)-1; self.reset(); self.gated = True; return if key == 'H': self.aid = not self.aid; return if self.state.won: return if key == 'O': self.advice = bounded_oracle(self.level, self.state); self.reason = 'advice'; return if self.conclusion: if key == 'TAB': self.conclusion = False; return if key in ('LEFT', 'RIGHT'): self.cursor = (self.cursor + (-1 if key == 'LEFT' else 1)) % 3 elif key == 'Z': if self.cursor in self.selected: self.selected.remove(self.cursor) else: self.selected.add(self.cursor) elif key == 'SPACE': self.commit(('submit', tuple(self.selected))) return if key in ('UP', 'DOWN'): self.witness = (self.witness + (-1 if key == 'UP' else 1)) % 3 elif key in ('LEFT', 'RIGHT'): self.target = (self.target + (-1 if key == 'LEFT' else 1)) % 3 elif key == 'Z': self.commit(('ask', self.witness, self.target)) elif key == 'SPACE': self.conclusion = True; self.reason = 'choose' def draw(self, p): p.cls(1); s = self.state; level = self.level p.text(16, 12, 'MOUSE COUNCIL / STAGE ' + str(STAGE) + ' / MEETING ' + NAMES[self.level_index], 10) p.text(16, 32, 'TICKETS ' + str(s.tickets) + ' / GOAL <= ' + str(level.goal) + ' / ' + ('DONE' if s.won else 'THINK'), 7) for i in range(3): x = 65 + i * 156 p.circ(x-8, 66, 7, (6,11,15)[i]); p.circ(x+8, 66, 7, (6,11,15)[i]) p.circ(x, 78, 13, (6,11,15)[i]); p.pset(x-4,77,1); p.pset(x+4,77,1) p.text(x-2, 99, NAMES[i], 7) p.text(x-46, 115, 'WEAK: '+LABELS[level.weaknesses[i]],7) p.text(x-46, 130, 'SEES: '+''.join(NAMES[t] for t in level.visible[i]),13) p.text(16, 151, 'WEAK ONLY: CHEESE > APPLE > NUT > CHEESE', 6) p.rectb(12, 174, 466, 67, 5) if self.conclusion: p.text(22, 185, 'SELECT ALL POSSIBLE CHEESE CARRIERS', 10) for i in range(3): x = 40 + i * 146 p.rectb(x-7, 205, 113, 25, 10 if i == self.cursor else 5) p.text(x, 215, NAMES[i] + (' [YES]' if i in self.selected else ' [NO]'), 11 if i in self.selected else 13) else: p.text(22, 185, 'ASK ' + NAMES[self.witness] + ' ABOUT ' + NAMES[self.target] + ' : Z', 10) weak = level.weaknesses[self.witness] p.text(22, 209, LABELS[(weak+1)%3]+' MAY MEAN '+LABELS[weak]+' OR '+LABELS[(weak+1)%3], 6) p.text(22, 225, 'UP/DOWN: WITNESS LEFT/RIGHT: TARGET', 7) worlds = hypotheses(level, s.cards) p.text(16, 257, 'NOTEBOOK', 10) for i, card in enumerate(s.cards): p.text(16+(i%2)*235, 275+(i//2)*16, NAMES[card.witness]+' > '+NAMES[card.target]+' : '+LABELS[card.answer], 7) p.text(16, 330, 'WORLD COUNT: '+(str(len(worlds)) if self.aid else 'HIDDEN'), 13) p.text(196, 330, 'CANDIDATES: '+(' '.join(NAMES[i] for i in candidates(level,s)) if self.aid else 'HIDDEN'), 6) if self.advice: cost, question = self.advice advice = 'NO GUARANTEED PLAN' if cost is None else 'READY TO SUBMIT' if cost == 0 else 'ASK '+NAMES[question[0]]+'>'+NAMES[question[1]]+' / WORST '+str(cost) p.text(16, 349, advice, 10) p.text(16, 372, MESSAGES[self.reason], 11 if s.won else 7) p.text(16, 397, FOOTER1, 7) p.text(16, 415, FOOTER2, 7) p.text(16, 433, 'R: RESET STATE (NOT SOURCE) ESC: QUIT', 13) def make_demo_keys(): return ('3','RIGHT','Z','O','SPACE','Z','RIGHT','Z','SPACE') def main(headless=False, screenshot=None, demo=False): # Validate and resolve BEFORE importing Pyxel: relative screen.save paths # otherwise refer to the script folder, not the caller's working directory. from pathlib import Path if demo and not headless: raise ValueError('demo requires headless mode') if screenshot: target = Path(screenshot) if not headless or target.suffix.lower() != '.png' or not target.parent.is_dir() or target.exists(): raise ValueError('use a new .png in an existing directory with headless mode') screenshot = str(target.resolve()) import pyxel as p p.init(492, 452, title='Mouse council lesson '+str(STAGE), fps=30, display_scale=2, headless=headless) app = App(); sequence = make_demo_keys() if demo else () names = ('UP','DOWN','LEFT','RIGHT','Z','SPACE','TAB','H','O','X','R','1','2','3') keys = {name:getattr(p, 'KEY_'+name) for name in names} limit = max(20, len(sequence)*2+8) 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) 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') args = parser.parse_args() try: main(args.headless_smoke, args.screenshot, args.demo_checkpoint) except ValueError as error: parser.error(str(error))