推理・観察LESSON 76正直な見間違いから候補集合を絞る有限推理パズル原作033

ねずみ会議の作り方

見間違いにも、決まったルールがある。
証人のネズミが「1つの品だけを見て、どう見えたか」という見え方を分けて作り、証言と矛盾しない荷物の並びを絞りこみます。最後まで残るチーズ係の候補の集まりと、質問の計画・Undo も作ります。

完成版の遊び方公開された「苦手な品」と証言を照らし合わせ、証拠では外せないチーズ係の候補を、全員まとめて提出します。

ねずみ会議の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Bの自動描画です。下の教材は知覚の実験から始め、04/05で三つの会議を扱います。
PLAY / 試験公開

完成サンプルをブラウザーで遊ぶ

この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。

完成版の主な操作:上下目撃者/左右対象、Z質問、Space結論/提出、左右+Z候補選択、X戻す、1〜3会議

ボタンを押すと、ゲームの読み込みが始まります。

画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。

うまく表示されないとき

ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。

同じPyxelアプリをダウンロード(.pyxapp) / 別のタブで開く

見え方と証言から、候補の集まりを絞る。

いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、5つのステップで作っていきます。どのステップのファイルもそれだけで動くので、動きを確かめてから次へ進みましょう。

1. 小さな関数・割った余り・書き換えない観察の記録
2. 並べ方(順列)・all・候補の集まりへの取り出し
3. 質問の回数の上限・集まりが一致するか・判定の順番
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
変数・if・for・関数と戻り値 / tuple・list・set、番号(添字)は0から数える / dataclass は本文で補足、再帰(関数が自分を呼ぶこと)と cache(結果の使い回し)は04で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
上下:01は苦手、02以降は証人 / 左右:01は公開サンプル、02以降は対象、結論欄は候補カーソル / Z:01観察、02以降質問、結論欄は選択切替 / Space:03以降は結論欄へ移る/提出 / Tab:03以降、結論から質問欄へ(Undoではない) / H:03以降、配置数と候補支援を切替 / 1/2/3:04以降、会議A/B/Cを初期化して選択 / O:04以降、証拠だけから最悪時の質問計画 / X:05のみ状態が変わった判断をUndo / R:同じ会議の初期状態へ。ソースを再読込しない / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_perception.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。

教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。

STEP 01 / 05

一つの観察を、見間違いの規則へ渡す

小さな関数・割った余り・書き換えない観察の記録。

まずは、推理を始める前の実験室です。見せる荷物を左右キーで、証人 A の苦手な品を上下キーで変え、Z で「A が B を見て言った証言」を1枚作ります。品の番号は、0=CHEESE(チーズ)、1=APPLE(りんご)、2=NUT(木の実)です。この段階には、隠された並び、質問券、勝ち負けはありません。元のゲームの「見え方のルール」だけを取り出しています。

perceive(item, weakness) は、見る品 item と苦手な品 weakness が同じときだけ、(item+1)%3(次の番号の品)を返します。苦手がチーズなら、チーズはりんごに見え、りんごはそのままりんごに見えます。つまり「りんご」という返事だけでは、本当はどちらだったか区別できません。すべての品を1つ先にずらすルールではなく、苦手な1品だけを見間違えるルールです。

Z を押すと Observation(0,1,self.item) を作り、testimony にはその観察と苦手な品だけを渡します。Observation が持つ情報は、証人・見た相手・見た1品です。3匹の荷物の並び全体を関数に渡さない作りを、ここで読みましょう。証人は、自分に見えたとおりの品を正直に答えます。わざとうそを選んでいるわけではありません。

Card は frozen=True の dataclass です。Z で作った LAST CARD はその時点の記録なので、そのあとカーソルや苦手な品を変えても書き換わりません。新しい条件でもう一度見るには、また Z を押します。この実験室で見せている荷物を、あとの推理の画面の隠された並びと混同しないようにしましょう。

下のコードは 01_perception.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

01_perception.py / 注目する部分
def perceive(item, weakness):
    return (item + 1) % 3 if item == weakness else item

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 01_perception.py
macOS / Linux
.venv/bin/python 01_perception.py
画面で見ること起動したときは、荷物 CHEESE・苦手 CHEESE です。Z を押すと LAST CARD は APPLE になります。右 → Z で荷物を APPLE にしても返事は APPLE なので、同じ返事が違う品から生まれることがわかります。
  • 苦手な1品だけを、次の品に見間違える
  • 見た1品だけを証言の関数に渡す
  • 過去の Card と、今のカーソルを区別する
つまずきやすいところ(item+1)%3 を if の外に出すと、苦手でない品まで変わってしまいます。LAST CARD は過去の1枚で、今の荷物から毎回計算し直す表示ではありません。
少し変えてみよう

START_WEAKNESS = 0 を START_WEAKNESS = 1 に変えてみましょう。保存して、動いている画面を Esc で終了し、同じ 01_perception.py をもう一度起動してください。Z の返事は CHEESE になります。確認したら0に戻して、保存・終了・起動し直します。R はゲームの状態を戻すだけで、書き換えたコードを読み直すわけではありません。

01_perception.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(149行)
01_perception.py / 全文
# 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.

"""ねずみ会議・教材1/5。単独実行できる段階別プログラム。"""
from dataclasses import dataclass, replace, asdict
from itertools import permutations
from functools import lru_cache
import hashlib, json

NAMES = 'ABC'

ITEMS = ('チーズ', 'りんご', '木の実')

@dataclass(frozen=True)
class Observation:
    witness: int
    target: int
    item: int

@dataclass(frozen=True)
class Card:
    witness: int
    target: int
    answer: int

def perceive(item, weakness):
    return (item + 1) % 3 if item == weakness else item

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))

STAGE = 1
LABELS = ("CHEESE", "APPLE", "NUT")

# 学習用の公開された一品。隠れた配置全体は証言関数へ渡さない。
START_ITEM = 0
START_WEAKNESS = 0

class App:
    def __init__(self):
        self.reset()

    def reset(self):
        self.item = START_ITEM
        self.weakness = START_WEAKNESS
        self.card = None
        self.gated = False
        self.message = 'READY: Z RECORDS ONE OBSERVATION'

    def control(self, pressed, held):
        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.message = 'RELEASE ALL KEYS'; return
        if len(pressed) != 1: return
        key = next(iter(pressed)); self.gated = True
        if key in ('LEFT', 'RIGHT'):
            self.item = (self.item + (-1 if key == 'LEFT' else 1)) % 3
        elif key in ('UP', 'DOWN'):
            self.weakness = (self.weakness + (-1 if key == 'UP' else 1)) % 3
        elif key == 'Z':
            observation = Observation(0, 1, self.item)
            self.card = testimony(observation, self.weakness)
            self.message = 'RECORDED: CURSOR EDITS DO NOT REWRITE THIS CARD'

    def draw(self, p):
        p.cls(1)
        p.text(16, 12, 'MOUSE COUNCIL / STAGE 1 / PERCEPTION LAB', 10)
        p.text(16, 40, 'PUBLIC SAMPLE ITEM: ' + LABELS[self.item], 7)
        p.text(16, 58, 'WITNESS A WEAKNESS: ' + LABELS[self.weakness], 7)
        p.text(16, 82, 'ONLY THE WEAK ITEM LOOKS LIKE THE NEXT ITEM.', 6)
        p.text(16, 98, '0 CHEESE -> 1 APPLE -> 2 NUT -> 0 CHEESE', 6)
        for item in range(3):
            y = 139 + item * 36
            p.rectb(14, y-7, 462, 27, 10 if item == self.item else 5)
            p.text(24, y, LABELS[item] + '  ->  ' + LABELS[perceive(item, self.weakness)], 7)
        p.text(16, 266, 'LAST CARD A > B: ' + (LABELS[self.card.answer] if self.card else '(none)'), 11)
        p.text(16, 290, 'Observation(witness=0, target=1, item=one item)', 13)
        p.text(16, 308, 'No hidden assignment is passed to testimony().', 13)
        p.text(16, 362, self.message, 10)
        p.text(16, 402, 'LEFT/RIGHT: ITEM  UP/DOWN: WEAKNESS  Z: RECORD', 7)
        p.text(16, 420, 'R: RESET  ESC: QUIT / NO TICKETS OR SOLVING YET', 7)

def make_demo_keys():
    return ('Z', 'RIGHT', 'Z')

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))
STEP 02 / 05

六通りの配置を、証言でふるいにかける

並べ方(順列)・all・候補の集まりへの取り出し。

3匹の A/B/C が、チーズ・りんご・木の実を1個ずつ運んだ「会議 A」を扱います。並びの位置が運んだネズミ、値が品の番号です。(0,1,2) なら、A がチーズ・B がりんご・C が木の実です。permutations(range(3)) で、ありえる6通りの並びをすべて作ります。同じ品を2匹に割り当てた並びは、はじめから含めません。

上下キーで証人を、左右キーで質問する相手(運んだネズミ)を選び、Z で質問します。画面の SEES は、その証人から見える相手です。自分自身への質問は unseen(見えない)、ノートにすでにある「同じ証人・同じ相手」の質問は repeat(くり返し)で、状態も券も変わりません。元のゲームの会議 A の質問券は1枚ですが、この段階は読み比べのために6枚にしています。最初からある証言 A>C:NUT も残します。まだ結論の提出と勝ち負けはありません。

hypotheses は、ありえる並びを1つずつ試します。その並びで相手が運んでいる品を、証人の苦手な品のルールで見間違えさせた結果が、すべての Card の答え answer と一致すれば、その並びを残します。ここで使うのは公開されている苦手な品とノートだけで、本当の並び level.truth は使いません。実際の新しい返事を作る answer は本当の並びの1品を見るので、2つの役割を分けておきます。

「並びの候補」と「チーズ係の候補」は別のものです。cheese_candidates は、それぞれの並びでチーズ(0)を運んでいるネズミを取り出し、重なりを set でまとめます。WORLD COUNT は残っている並びの数、CANDIDATES は残っているチーズ係です。この段階では、両方を手助けとして表示しています。チーズ係の候補が1匹になっても、ほかの2匹の品まで全部決まったとは限りません。

下のコードは 02_evidence.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

02_evidence.py / 注目する部分
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))))

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 02_evidence.py
macOS / Linux
.venv/bin/python 02_evidence.py
画面で見ること最初のノートは A>C:NUT、WORLD COUNT 2、CANDIDATES A B、TICKETS 6 です。下 → 下 → 左 → Z で C に A のことを質問すると、返事は CHEESE、WORLD COUNT 1、CANDIDATES A、TICKETS 5 になります。もう一度 Z を押しても、券は5のままです。
  • 荷物は1個ずつなので、並びは6通り
  • すべての証言を満たす並びを残す
  • 並びの数と、チーズ係の候補の数を分ける
つまずきやすいところ本当の並びと一致するものだけを残すと、推理ではなく答え合わせになってしまいます。ありえる並びを1つずつ同じ見間違いのルールで調べ、証言と一致するものを全部残します。
少し変えてみよう

level の中に1か所だけある tickets=6 を tickets=1 に変え、保存・Esc で終了・同じ 02_evidence.py を起動し直してみましょう。Z → 下 → 下 → 左 → Z と押します。最初の A>B の質問で券を使い切り、最後の C>A は NO TICKETS LEFT(券がない)になります。候補は A B のままです。6に戻すときも、保存・終了・起動し直しをします。

02_evidence.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(242行)
02_evidence.py / 全文
# 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.

"""ねずみ会議・教材2/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')

STAGE = 2
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 = 'UP/DOWN: WITNESS  LEFT/RIGHT: TARGET  Z: ASK'
FOOTER2 = 'SIX WORLDS: ONE OF EACH ITEM / MEETING A ONLY'

class App:
    def __init__(self):
        self.level_index = 0
        self.reset()

    @property
    def level(self):
        return replace(LEVELS[self.level_index], tickets=6)

    

    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:])
        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 self.state.won: 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))
        

    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)
        p.text(16, 349, 'PRACTICE: 6 TICKETS / NO SUBMIT YET', 14)
        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 ('DOWN','DOWN','LEFT','Z')

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))
STEP 03 / 05

本当の一匹ではなく、証拠が残す集合を提出

質問の回数の上限・集まりが一致するか・判定の順番。

会議 A の質問券を、元のゲームどおり1枚に戻します。Space で結論の欄に移り、左右キーで A/B/C のカーソルを動かし、Z でそのネズミを選ぶ・外すを切り替え、もう一度 Space で提出します。Tab を押すと質問の欄に戻ります。Tab は状態を戻しません。選んだだけでは提出されず、質問券も使いません。まだ Undo はなく、やり直しは R です。

提出で調べるのは、「本当のチーズ係を当てたか」ではありません。まず selected(選んだネズミ)を sorted(set(...)) で、順番と重なりのない集まりにそろえ、証拠から残る候補の集まりと完全に一致するかを調べます。一致しなければ mismatch(不一致)。一致しても、会議の目標 goal より人数が多ければ wide(絞りきれていない)です。両方を満たしたときに初めて won=True になります。

最初の状態で本当のチーズ係は A ですが、証拠からは A と B が残ります。A だけを出すと mismatch、A と B を出すと wide です。C に A のことを聞けば A に絞れるので、A だけで成功します。当てずっぽうと、証拠にもとづく結論の違いを、ルールとして作っています。結果が出たあとの質問・選択・再提出は画面の側で止めますが、R はそのまま使えます。

H で WORLD COUNT と CANDIDATES の手助けの表示を隠せますが、提出の結果は表示されます。mismatch は「選んだ集まりが違う」こと、wide は「集まりは合っているが目標の人数を超えている」ことを知らせます。そのため、H を押しても、画面全体が答えの情報を隠している、とは言えません。見え方の関数や候補の計算が使える情報の制限と、画面全体で見せる情報は、別々に考えます。

下のコードは 03_conclusion.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

03_conclusion.py / 注目する部分
    if selected != candidates(level, state):
        return (state, 'mismatch')
    if len(selected) > level.goal:
        return (state, 'wide')

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 03_conclusion.py
macOS / Linux
.venv/bin/python 03_conclusion.py
画面で見ること下 → 下 → 左 → Z → Space → Z → Space と押します。C>A:CHEESE が記録され、A [YES] で DONE になり、券は0です。R でやり直し、質問せずに Space → Z → Space と押すと mismatch。右 → Z → Space で A B を出すと wide です。
  • 選ぶことと、提出することを分ける
  • 証拠から残る候補の集まりと、完全に一致させる
  • 手助けの表示を隠しても、判定は情報を返す
つまずきやすいところselected に本当のチーズ係が含まれているだけで成功にしないでください。余分な候補も、根拠なく外した候補も、受け付けません。wide は集まりが一致したあとで判定するので、mismatch とは意味が違います。
少し変えてみよう

LESSON_GOAL = 1 を LESSON_GOAL = 2 に変え、保存・Esc で終了・同じ 03_conclusion.py を起動し直してみましょう。質問せずに Space → Z → 右 → Z → Space で A B を提出すると、券1のまま DONE になります。本当の答えは変えず、許される結論の広さだけが変わりました。1に戻して、保存・終了・起動し直します。

03_conclusion.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(259行)
03_conclusion.py / 全文
# 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.

"""ねずみ会議・教材3/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')

STAGE = 3
LABELS = ("CHEESE", "APPLE", "NUT")
LESSON_GOAL = 1
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 = 'NO UNDO YET. R STARTS THE SAME MEETING AGAIN.'

class App:
    def __init__(self):
        self.level_index = 0
        self.reset()

    @property
    def level(self):
        return replace(LEVELS[self.level_index], goal=LESSON_GOAL)

    

    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 == 'H': self.aid = not self.aid; return
        
        if self.state.won: 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)
        p.text(16, 349, 'H HIDES THE AID; SUBMISSION STILL RETURNS FEEDBACK.', 13)
        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 ('DOWN','DOWN','LEFT','Z','SPACE','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))
STEP 04 / 05

三会議と、返事を先読みしない質問計画

区別できない並びのまとまり・返事ごとの分かれ道・最悪の場合を調べる再帰の探索。

1/2/3 キーで会議 A/B/C を切り替え、それぞれの元のゲームの本当の並び・苦手な品・見える範囲・最初の証言・券・目標を使います。切り替えると状態は最初に戻ります。会議 B は券2・目標1、会議 C は券2・目標2 です。会議 B の A と B はチーズが苦手で、会議 C では全員がチーズが苦手なので、何回見ても消せないあいまいさが残ります。

O は、元のゲームのルールにある bounded_oracle を呼ぶ、教材用に加えた操作です。今残っている並びごとに、質問したときの返事を計算し、同じ返事になる並びを1つのまとまり(情報集合)にします。それぞれの分かれ道を再帰で調べ、どんな返事が来ても、券の枚数以内で目標の人数まで絞れる質問だけを選びます。WORST は、最悪の場合に必要な質問の数です。実際に返ってくる答えを先に見て選ぶ機能ではありません。

探索は、まだしていない質問が最大6問、並びが最大6通り、券は減っていくので、必ず終わります。cache は、同じまとまり・同じ済んだ質問・同じ残りの券の結果を使い回します。最悪の場合の回数が同じなら、legal_questions で先に出てくる質問を選びます。候補の人数がもう目標以下なら READY TO SUBMIT(提出できる)、確実に絞れる方法がなければ NO GUARANTEED PLAN と表示します。O を押しただけでは、証言・券・勝ち負けは変わりません。

会議 B で実際に C>A を1回聞くと B に絞れますが、始めたときに「どんな返事でも確実に絞れる」と言える回数は2問です。あとから知った近道と、前もって立てる情報の計画を、分けて考えます。会議 C では、本当は A がチーズでも、公開されたルールとすべての証言からは「A か B のどちらか」までしかわかりません。元のゲームの目標2は、このあいまいさを正直に残したまま成功できるルールです。

下のコードは 04_information_plan.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

04_information_plan.py / 注目する部分
            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)

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 04_information_plan.py
macOS / Linux
.venv/bin/python 04_information_plan.py
画面で見ること3 → 右 → Z で A に C のことを質問すると NUT、残る候補は A B、券1です。O を押すと READY TO SUBMIT。Space → Z → 右 → Z → Space で A B を選んで DONE です。2 で会議 B に切り替えて O を押すと、ASK A>C / WORST 2 と表示されます。
  • 3つの会議を、別々の Level として扱う
  • 返事ごとに、ありえる世界を分けて調べる
  • 実際にわかった近道と、最悪の場合の保証を分ける
つまずきやすいところ本当の次の返事の分かれ道だけを探索すると、まだ知らないはずの情報を使った計画になってしまいます。最悪の場合(worst-case)は、それぞれの返事で必要な回数の最大を取り、すべての分かれ道で解けることを確かめます。
少し変えてみよう

R で会議の最初の状態に戻したあと、3 → 右 → Z → H → Space → Z → Space と押してみましょう。手助けの表示が HIDDEN でも、A だけの提出は mismatch です。そのまま 右 → Z → Space で A B にすると DONE になります。H が判定を消していないことを、実際に確かめましょう。これはキー操作の課題なので、コードを書き換える必要はありません。

04_information_plan.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(293行)
04_information_plan.py / 全文
# 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))
STEP 05 / 05

証言・質問券・成功を一緒に巻き戻す

書き換えない状態・履歴・ゲームのルールと表示の状態の境目。

Timeline に、今の State と、過去の State を並べたタプルを持たせ、成功した質問や、成功した提出の直前の状態を保存します。X で一番新しい1件を元に戻します。State は cards・tickets・won をまとめて持つので、証言だけを消して券を返し忘れたり、成功の印だけが残ったりはしません。元のゲームの model.py のデータクラスと関数は、この段階で全部そのまま入っています。

無効な質問、同じ質問、券切れ、失敗した提出では State が変わらないので、履歴は増えません。カーソル、H、O、結論の欄への出入りも、ゲームの判断ではありません。成功した提出は won を変えるので1件と数えます。成功の画面でも X を先に受け付けるので、成功する直前の証拠に戻れます。履歴が空なら no_undo です。

Undo したあとは、質問の欄に戻り、選んだネズミを空にする、というのがこの教材の表示の決まりです。証人・相手のカーソルと H の設定は、巻き戻しません。たとえば最後に C>A を選んでいれば、2回 Undo してもカーソルは C>A のままです。R は、その会議の最初の状態・カーソル・表示の手助けに戻し、履歴を空にします。

元のゲームの submit 関数そのものには、won のときに最初で止める分かれ道はありません。教材も元のゲームと同じく、画面の側で結果のあとの再提出を止め、X や R などは別に受け付けます。純粋な関数の決まりと、ユーザーの入力をどこまで受け付けるかを、混同しないことが大切です。照合のテストは、3つの会議×6通りの本当の答え×最初の券までの全質問の列558件と、そのそれぞれの時点での質問9通り・提出8通りです。作り変えた好きな問題や、限りなく長い Undo の履歴まで、すべて証明したわけではありません。

下のコードは 05_undo.py の一部です。前のステップから追加・変更したところだけを抜き出しています。

05_undo.py / 注目する部分
    if action[0] == 'undo':
        return (Timeline(timeline.past[-1], timeline.past[:-1]), 'undo') if timeline.past else (timeline, 'no_undo')

実行して確かめる

教材のZIPを展開したフォルダーで、次のコマンドを実行します。

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること起動したら、下 → 下 → 左 → Z → Space → Z → Space で A だけを提出して DONE、券0・HISTORY 2 です。X を押すと、未完了・券0・HISTORY 1。さらに X を押すと、最初の証言だけになり、券1・HISTORY 0 です。
  • 証言と券と成功を、1つの State にまとめる
  • 成功しなかった操作と、カーソルの操作は、履歴に入れない
  • 結果が出たあとも、Undo を受け付ける
つまずきやすいところ1回の Undo で「画面で最後に押したキー」を戻そうとしないでください。保存するのは、State が変わった判断です。選択の切り替えや失敗した提出を同じ履歴に混ぜると、何を1手として戻すのかがあいまいになります。
少し変えてみよう

R → 下 → 下 → 左 → Z → Space → Z → Space → X → X と押して、券1・HISTORY 0 を確かめましょう。そのまま Z を押すと、残っていたカーソル C>A でもう一度質問します。券0・HISTORY 1 に戻り、同じ CHEESE の証言が得られます。キー操作の課題なので、書き換えは不要です。コードを変える場合は、必ず保存・Esc で終了・同じ 05_undo.py を起動し直します。

05_undo.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(312行)
05_undo.py / 全文
# 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.

"""ねずみ会議・教材5/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

@dataclass(frozen=True)
class Timeline:
    state: State
    past: tuple = ()

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 step(level, timeline, action):
    if action[0] == 'undo':
        return (Timeline(timeline.past[-1], timeline.past[:-1]), 'undo') if timeline.past else (timeline, 'no_undo')
    state, reason = ask(level, timeline.state, *action[1:]) if action[0] == 'ask' else submit(level, timeline.state, action[1])
    if state == timeline.state:
        return (timeline, reason)
    return (Timeline(state, timeline.past + (timeline.state,)), reason)

def state_hash(state):
    return hashlib.sha256(json.dumps(asdict(state), sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()

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 = 5
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: PLAN  X: UNDO'

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(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.timeline, self.reason = step(self.level, self.timeline, action)
        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 key == 'X':
            self.commit(('undo',)); self.conclusion = False; self.selected.clear(); 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(326, 349, 'HISTORY '+str(len(self.timeline.past)), 13)
        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','SPACE','Z','RIGHT','Z','SPACE','X')

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))
AFTER STEP 05

仕組みがわかったら、元の完成版を読んでみよう。

ルールのまとめ:3匹がチーズ・りんご・木の実を1つずつ運びます。証人は見える1品だけを受け取り、苦手な品だけを次の品に見間違えます。公開されたルールとすべての証言に合う並びから、チーズ係を全部書き出し、その集まりと完全に一致し、しかも会議の目標の人数以下なら成功。成功した質問と成功した提出は Undo できます。

ここまでに作ったのは、元のゲームの仕組みだけを取り出して作り直した「簡略版」です。元のゲームのソースは手を加えずに、別のZIP(元の完成版ソース)に入れてあります。

比べる点5ステップの教材元の完成版
段階01 公開した荷物での見え方の実験、02 会議 A の券を6枚にゆるめ提出なし、03 元のゲームの会議 A と提出、04 3つの会議と情報の計画、05 元のゲームのルール全部と Undo。最初から、3つの会議、決まった枚数の券、候補の提出、Undo があります。
表示と入力英数字(ASCII)の表示で、1ファイルだけで動きます。表紙・説明での一時停止・録画・会議 B の切り替えは省いています。見間違いを逆にたどる補助はいつも表示。H は候補に加えて並びの数も隠します。Tab で質問の欄に戻ります。Noto フォントの日本語の文字、表紙と説明、H での候補の手助け、会議 B の逆にたどる補助、M で表紙があります。X は Undo と、質問の欄に戻る操作です。
追加した説明のための機能WORLD COUNT と、O での最悪の場合の計画を表示。R は画面の手助けも最初に戻します。Undo のときは選んだネズミを空にします。候補の手助けは最初からオン。bounded_oracle はルールの中にありますが、ふつうの画面から O で呼ぶ機能はありません。
情報の限界同じ公開された State のまま本当の答え truth だけを変えても、ふつうの描画・候補の計算・質問の計画が変わらないことに限ってテストしています。H で隠しても、提出の mismatch/wide は情報を返します。見え方の入力を制限していることだけから、画面全体が答えの情報を出していないとは言えません。最初の状態の候補の手助けも表示されます。

完成版のコードで、次に読むところ

  1. model.py の observe / testimony:関数に渡す情報の境目
  2. model.py の hypotheses / cheese_candidates:並びとチーズ係の2段階
  3. model.py の submit:mismatch → wide → 完了、の順番
  4. model.py の bounded_oracle:返事ごとの分かれ道と、最悪の場合の回数
  5. model.py の step / Timeline:Undo
  6. app.py の aid / inverse:手助けの表示と、提出の結果の関係
自分のゲームに変えるなら

最初は、数字かルールを1つだけ変えてみましょう。遊ぶ前に「どう変わるか」を予想しておくと、仕組みがよくわかります。慣れてきたら、見た目や音も足してみてください。元のファイルは残しておき、コピーした別のフォルダーで改造すると、いつでも元に戻せます。

教材をダウンロードする

無料で、登録なしでダウンロードできます。ZIPファイルは必ず展開してから、中のファイルを実行してください。元の完成版は、同じZIPに入っている assets フォルダー(画像などの素材)も一緒に使います。

コードと図形はMITライセンス、元の完成版に含まれる日本語の文字データ(Noto由来)はSIL OFL 1.1です。配ったり改造したりするときは、ZIPに入っているLICENSEなどの権利表示を残してください。出典・権利について

検証について

どこまで動作を確認したかを見る

2026年10月5日以降、Linux(Python 3.12・Pyxel 2.9.9)で、教材のコードとゲームのルールを自動テストしています。全500ステップの教材が、実際のPyxelで起動して画面を描けることも確かめました。元の完成版も、自動で遊ぶデモが起動して画面を描き、正常に終わることを確認しています。

この確認は、画面を表示しない自動テストで行ったものです。Windows・Macの実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る