シミュレーションLESSON 90同時更新と有限給餌を学ぶ架空の水槽パズル原作076

紙の水族館の作り方

同じ水槽でも、何が先に起きるかで結果が違う。
ミジンコが流される動きと、魚2匹の同時の移動の計画、交代で食べる順番、食べ残しの餌・藻・ミジンコの誕生を、段階を追って処理します。限られた餌の中で、1刻(時間の区切り1つ)先の予測と、入力を再生して戻す Undo を作ります。

完成版の遊び方餌と隠れ家の置き方を決めて、魚・藻・ミジンコの関係を8刻ながめ、2匹の魚に食事を届けます。

紙の水族館の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版Aの自動描画です。下の教材は漂流の実験から始めます。架空の整数模型で、現実の飼育条件を示すものではありません。
PLAY / 試験公開

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

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

完成版の主な操作:左右区画/上下餌、Enter給餌と一刻、Space待つ、Tab住処/詳細、Z予告、X戻す

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

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

うまく表示されないとき

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

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

同時に行う更新と、食事・成長の順番を分ける。

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

1. 書き換えない状態・受け入れ枠の予約・合計が変わらないルール
2. 点数・動ける範囲・同点のときの優先順位・同時の計画
3. 限られた資源・入力の確認・順番のある取り合い
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python の変数・if・for・関数 / tuple と番号(添字)0/1/2 / dataclass、replace、例外(エラー)は本文とコードで追います
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Space:餌なしで一刻。01/02は漂流・移動 / Enter:選択した粒数で一刻 / 左右:区画選択 / 上下:03以降で0/1/2粒 / Tab:03以降、t0で二つの隠れ家の六配置を循環 / 1/2/3:03以降でA/B/Cへ初期化 / B:04以降、残餌の藻化ON/OFFして初期化 / X:05で最後の一刻を入力再生してUndo / R:読込済み初期状態へ戻す。コード再読込ではない / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_drift.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

流れを一斉に動かす

書き換えない状態・受け入れ枠の予約・合計が変わらないルール。

水槽を、左 L・真ん中 M・右 R の3つに分けます。ミジンコの数 d と、隠れ家の数 shelters は tuple です。1つの隠れ家は2匹を守り、3つの区画に合わせて2つ置きます。Lab は frozen=True の dataclass なので、advance は元の状態を書き換えず、replace で次の状態を作ります。画面を描く draw は数字を読むだけです。Space か Enter を離してから押すたびに1刻だけ進み、8刻で止まります。前提は、変数、if、for、関数、tuple です。dataclass と replace は、「記録の型」と「一部だけを差しかえた新しい記録」と考えてください。

この段階の最初の値は、教材専用の d=(5,0,3)、shelters=(1,0,1) です。元のゲームの状況 A/B/C の最初のデータではありません。魚・餌やり・食事・誕生はまだありません。1刻後は (4,2,2) です。左から真ん中へ、右から真ん中へ、1匹ずつ移り、合計8匹は変わりません。守られている2匹を超えた分が何匹いても、1つの区画から1刻に出ていくのは1匹だけです。真ん中に着いたミジンコを、同じ刻のうちにさらに右へ流してはいけません。

FLOW=(1,2,1) は、L→M、M→R、R→M という行き先の表です。drift は、最初に slots=[12-dL,12-dM,12-dR](各区画にあと何匹入れるか)を決めます。左・真ん中・右の順に予約し、受け入れ枠があるときだけ moves に入れます。全部の予約が終わってから、元の数を減らしたり増やしたりします。この「先に計画して、あとで反映する」のが、同時の処理です。

出ていくことで空いた場所を、同じ刻の受け入れ枠に戻さない点に注意しましょう。たとえば (1,12,0)、隠れ家 (0,0,2) では、真ん中から1匹が右へ出ていっても、左の1匹は、満員だった真ん中に入れず、(1,11,1) になります。また (5,11,5)、隠れ家 (1,0,1) では、真ん中の最後の1枠を左が先に予約するので、右からは入れません。これは現実の生き物や水の流れを再現したものではなく、紙で遊ぶゲームの決まりごとです。

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

01_drift.py / 注目する部分
    slots = [D_CAP - value for value in d]
    moves, blocked = [], []
    for source, dest in enumerate(FLOW):

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_drift.py
macOS / Linux
.venv/bin/python 01_drift.py
画面で見ること起動したときの D は 5/0/3、TOTAL D=8 です。Space を1回押すと D は 4/2/2、MOVES ((0,1),(2,1)) になります。8刻を過ぎると増えず、R で0刻に戻ります。
  • 出ていく前の受け入れ枠を、固定しておく
  • 左・真ん中・右の予約の順番を確かめる
  • 流されるだけなら、合計の数は変わらない
つまずきやすいところd を左から直接更新すると、着いたミジンコが2回流されたり、出ていって空いた枠を早く使い直したりしてしまいます。入力の d を読み続け、moves を決め終わるまで、結果には反映しません。
少し変えてみよう

Lab の d: tuple = (5, 0, 3) だけを (12, 12, 12) に変えてみましょう。保存 → Esc で終了 → python 01_drift.py で実行し直します。Space を押しても全部の区画が12のままで、BLOCKED(入れない)が3件です。元に戻すと 4/2/2 です。自動の確認は python exercises/01_full_capacity.py です。R はコードを読み直しません。

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

"""076 紙の水族館: 教材ステージ 1。架空の整数生態系。"""
STAGE = 1

from dataclasses import dataclass, replace

TICKS = 8
D_CAP = 12
FLOW = (1, 2, 1)

def _int(value):
    return isinstance(value, int) and not isinstance(value, bool)

def _vector(value, length, lo, hi):
    return (isinstance(value, tuple) and len(value) == length
            and all(_int(v) and lo <= v <= hi for v in value))

def _shelters(value):
    if not _vector(value, 3, 0, 2) or sum(value) != 2:
        raise ValueError("隠れ家は整数の3区画へ合計2チップ置いてください")

def drift(d, shelters):
    """Return (new counts, permitted (source,destination) moves, blocked moves).

    Receiving slots are frozen before any departure and reserved in L/M/R
    source order. This intentionally cannot reuse outgoing capacity this tick.
    """
    if not _vector(d, 3, 0, D_CAP):
        raise ValueError("daphnia must be a bounded integer triple")
    _shelters(shelters)
    slots = [D_CAP - value for value in d]
    moves, blocked = [], []
    for source, dest in enumerate(FLOW):
        if d[source] > 2 * shelters[source]:
            if slots[dest] > 0:
                moves.append((source, dest))
                slots[dest] -= 1
            else:
                blocked.append((source, dest))
    result = list(d)
    for source, dest in moves:
        result[source] -= 1
        result[dest] += 1
    return tuple(result), tuple(moves), tuple(blocked)

@dataclass(frozen=True)
class Lab:
    d: tuple = (5, 0, 3)
    shelters: tuple = (1, 0, 1)
    t: int = 0
    moves: tuple = ()
    blocked: tuple = ()

    @property
    def ended(self):
        return self.t == TICKS


def initial():
    return Lab()


def advance(state):
    if state.ended:
        return state
    d, moves, blocked = drift(state.d, state.shelters)
    return replace(state, d=d, t=state.t + 1, moves=moves, blocked=blocked)


# Teaching interface: independent of the original Controller.
from dataclasses import asdict

class App:
    def __init__(self):
        self.level = 'A'
        self.algae_enabled = True
        self.gated = False
        self.reset()

    def reset(self):
        self.state = initial(self.level, self.algae_enabled) if STAGE >= 3 else initial()
        self.cursor = 0
        self.amount = 0
        self.message = 'READY / SELECTING DOES NOT ADVANCE TIME'

    def press(self, key):
        if key == 'R':
            self.reset()
            return
        if STAGE >= 3 and key in ('1', '2', '3'):
            self.level = 'ABC'[int(key) - 1]
            self.reset()
            return
        if STAGE >= 4 and key == 'B':
            self.algae_enabled = not self.algae_enabled
            self.reset()
            return
        if STAGE == 5 and key == 'X':
            self.state = undo(self.state)
            self.message = 'UNDO / REPLAYED EARLIER INPUTS'
            return
        if STAGE >= 3 and key == 'TAB':
            if self.state.t:
                self.message = 'SHELTERS LOCKED AFTER TICK 0'
            else:
                index = SHELTER_LAYOUTS.index(self.state.shelters)
                self.state = edit_shelters(self.state, SHELTER_LAYOUTS[(index + 1) % len(SHELTER_LAYOUTS)])
                self.message = 'TWO SHELTER CHIPS / TAB CYCLES SIX LAYOUTS'
            return
        if key in ('LEFT', 'RIGHT'):
            self.cursor = (self.cursor + (1 if key == 'RIGHT' else -1)) % 3
        elif STAGE >= 3 and key in ('UP', 'DOWN'):
            self.amount = max(0, min(2, self.amount + (1 if key == 'UP' else -1)))
        elif key in ('SPACE', 'RETURN'):
            if self.state.ended:
                self.message = 'EIGHT TICKS FINISHED / R RESTARTS'
                return
            if STAGE < 3:
                self.state = advance(self.state)
            else:
                amount = self.amount if key == 'RETURN' else 0
                try:
                    self.state = step(self.state, self.cursor, amount)
                except ValueError:
                    self.message = 'NOT ENOUGH FOOD / TICK DID NOT ADVANCE'
                    return
            self.message = 'ONE TICK COMMITTED / RELEASE BEFORE NEXT PRESS'

    def control(self, pressed, held):
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
            return
        if len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True

    def prediction(self):
        if STAGE != 5 or self.state.ended:
            return None
        try:
            return forecast(self.state, self.cursor, self.amount)
        except ValueError:
            return None

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 12, 'PAPER AQUARIUM / STAGE ' + str(STAGE) + ' / ' + ('LAB' if STAGE < 3 else 'LEVEL ' + self.level), 10)
        p.text(12, 27, 'FICTIONAL INTEGER ECOSYSTEM / NOT AQUARIUM CARE ADVICE', 6)
        p.text(12, 45, 'TICK ' + str(s.t) + '/8  FLOW: L>M, M>R, R>M', 7)
        if STAGE >= 3:
            p.text(320, 45, 'FOOD LEFT ' + str(s.remaining_food) + '/10  MEALS ' + str(s.meals), 10)
        for z in range(3):
            x = 12 + z * 196
            p.rect(x, 68, 188, 216, 5)
            p.rect(x + 2, 70, 184, 203, 1)
            p.rectb(x, 68, 188, 216, 10 if z == self.cursor else 6)
            p.text(x + 8, 79, 'LMR'[z] + ' -> ' + 'MRM'[z], 10)
            p.text(x + 66, 79, 'D ' + str(s.d[z]) + ' / CAP 12', 11)
            for chip in range(s.shelters[z]):
                sx = x + 49 + chip * 72
                p.tri(sx - 21, 267, sx, 237, sx + 21, 267, 13)
                p.tri(sx - 10, 267, sx, 254, sx + 10, 267, 1)
            for i in range(s.d[z]):
                dx, dy = x + 21 + (i % 6) * 28, 194 + (i // 6) * 22
                p.circ(dx, dy, 3, 11)
                p.line(dx - 2, dy - 2, dx - 4, dy - 6, 7)
            if STAGE >= 2:
                for f in s.fish:
                    if f.zone != z:
                        continue
                    fx, fy = x + 46 + (f.id - 1) * 70, 142 + (f.id - 1) * 17
                    color = 10 if f.id == 1 else 14
                    p.tri(fx + 10, fy, fx - 8, fy - 8, fx - 8, fy + 8, color)
                    p.tri(fx - 7, fy, fx - 19, fy - 7, fx - 19, fy + 7, color)
                    p.pset(fx + 4, fy - 2, 0)
                    p.text(fx - 4, fy - 19, 'F' + str(f.id), color)
            if STAGE >= 3:
                particles = [f for f in s.food if f.zone == z]
                for i, f in enumerate(particles):
                    p.rect(x + 15 + (i % 8) * 20, 101 + (i // 8) * 9, 4, 4, 9 if f.age else 10)
                p.text(x + 8, 290, 'SHELTER ' + str(s.shelters[z]) + '  ALGAE ' + str(s.algae[z]), 6)
                p.text(x + 8, 302, 'FOOD ' + str(len(particles)) + '  PROTECTED ' + str(2 * s.shelters[z]), 7)
            else:
                p.text(x + 8, 290, 'SHELTER ' + str(s.shelters[z]) + '  PROTECTED ' + str(2 * s.shelters[z]), 6)
        if STAGE < 3:
            p.text(12, 325, 'DRIFT MOVES ' + str(s.moves) + ' / BLOCKED ' + str(s.blocked), 7)
            p.text(12, 341, 'CONSERVES TOTAL D = ' + str(sum(s.d)) + ' / NO MEALS OR BIRTHS', 11)
            if STAGE == 2:
                p.text(12, 357, 'POST-DRIFT SCORES ' + str(s.scores) + ' / BOTH USE OLD FISH POSITIONS' if s.t else 'NO FISH PLAN YET / SPACE TO COMPUTE THE FIRST TICK', 7)
        else:
            p.text(12, 325, 'NEXT INPUT: ' + 'LMR'[self.cursor] + ' / ' + str(self.amount) + ' PARTICLES', 10)
            if s.history:
                last = s.history[-1]
                p.text(12, 341, 'SCORES ' + str(last.scores) + ' / MEAL ORDER ' + str(last.meal_order), 7)
                kinds = ','.join(e.kind for e in s.events if e.kind in ('algae','birth','sink','eat_d','algae_full'))
                p.text(12, 357, 'EVENTS: ' + (kinds or 'feed / drift / meals')[:84], 11)
            if STAGE == 3:
                p.text(12, 377, 'STAGE 03: FOOD DOES NOT AGE / NO ALGAE CONVERSION OR BIRTHS', 6)
            else:
                p.text(12, 377, 'FOOD -> ALGAE: ' + ('ON' if s.algae_enabled else 'OFF') + ' / B RESTARTS COMPARISON', 6)
            if STAGE == 5:
                pred = self.prediction()
                if pred:
                    p.text(12, 396, 'NEXT TICK ONLY: FISH ' + str(pred.fish_positions) + ' MEALS ' + str(pred.meals) + ' D ' + str(pred.d), 10)
                else:
                    p.text(12, 396, 'NO NEXT FORECAST: FINISHED OR INSUFFICIENT FOOD', 8)
            if s.ended:
                p.text(12, 415, 'SUCCESS: BOTH MEALS >=3 AND TOTAL D >=2' if success(s) else 'FINISHED: CHECK MEALS >=3 EACH AND TOTAL D >=2', 11 if success(s) else 8)
        p.text(12, 440, self.message, 10)
        p.text(12, 461, 'ENTER COMMIT / SPACE WAIT / R RESET / ESC QUIT / RELEASE EACH KEY', 7)
        if STAGE >= 3:
            p.text(12, 477, 'LEFT/RIGHT ZONE / UP/DOWN 0-2 FOOD / TAB SHELTERS AT T0 / 1-3 LEVEL', 6)
        if STAGE == 5:
            p.text(12, 493, 'X UNDO BY REPLAY / R DOES NOT RELOAD CODE', 6)

def demo_keys():
    if STAGE < 3:
        return ('SPACE',)
    return ('RIGHT', 'UP', 'UP', 'RETURN', 'RIGHT', 'RETURN', 'LEFT', 'LEFT', 'RETURN', 'SPACE')

def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    import json
    from pathlib import Path
    p.init(612, 516, title='Paper aquarium lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
    app = App()
    sequence = demo_keys() if demo else ()
    keynames = ('LEFT','RIGHT','UP','DOWN','RETURN','SPACE','TAB','X','R','1','2','3','B')
    keys = {k: getattr(p, 'KEY_' + k) for k in keynames}
    limit = max(8, len(sequence) * 2 + 6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            if state_json:
                Path(state_json).write_text(json.dumps({'stage': STAGE, 'state': asdict(app.state), 'scope': 'Real Pyxel; synthetic controls, not physical keyboard or human playtest'}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            p.quit()
        if headless:
            i = p.frame_count // 2
            pressed = {sequence[i]} if demo and p.frame_count % 2 == 0 and i < 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
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone paper aquarium teaching stage')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    # Freeze the caller cwd BEFORE importing or initializing Pyxel.
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value:
            target = Path(value).resolve()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)
STEP 02 / 05

二匹の魚が同じ過去を見る

点数・動ける範囲・同点のときの優先順位・同時の計画。

Fish という記録を加えます。id は1/2、zone(いる区画)は0/1/2です。この段階は、元のゲームの状況 B と同じ最初の位置 (0,2)、ミジンコ (3,0,3)、隠れ家 (1,0,1) を使います。ただし、魚はまだ食べず、藻も増えません。Space で、流されたあとのミジンコの数を先に計算し、その数で2匹の魚の行き先を決めます。

区画の魅力は「2×餌の粒の数+max(0, ミジンコの数−2×隠れ家の数)」です。この段階では餌の tuple が空なので、隠れていないミジンコだけが点数になります。隠れ家の中のミジンコは、魅力に入れません。1刻目に流されたあとは (2,2,2)、点数は (0,2,0) です。両はしにいた2匹が、真ん中に集まります。動けるのは、今の場所と、となりの区画だけです。左から右へ一度に飛ぶことはありません。

同じ点数なら、相手の前の場所 → 自分の前の場所 → 左 → 真ん中 → 右、の順で、行ける候補を選びます。魚1の移動を反映してから魚2を計算すると、「相手の前の場所」が変わってしまいます。fish_destinations には元の state.fish を渡し、2匹ぶんの行き先 destinations を作り終えてから、replace します。「流されたあとの数」と「動く前の魚の位置」という、2つの時点を区別しましょう。

すべての区画が同じ点数で、魚の位置が (0,1) なら、元のゲームの答えは (1,0) です。2匹は、相手の前の場所に入れかわります。このとき、同じ区画に集まらないことが、同時の計画のよい確かめ方です。exercises/02_old_positions.py は、最初のデータを変えたコピーをメモリー上で実行し、この1刻を assert で確かめています。魚の食事の回数 meals はまだ0のままです。食事の回数が増える仕組みは、次で加えます。

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

02_fish_plans.py / 注目する部分
    destinations, scores = fish_destinations(state.fish, d, state.shelters, ())
    fish = tuple(replace(f, zone=destinations[i]) for i, f in enumerate(state.fish))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_fish_plans.py
macOS / Linux
.venv/bin/python 02_fish_plans.py
画面で見ること最初は、魚1が L、魚2が R にいます。Space で D=2/2/2、SCORES=(0,2,0) になり、2匹とも M に集まります。魚が動いても、ミジンコの合計6匹は変わりません。
  • 流されたあとの d で点数を作る
  • 2匹とも、同じ「前の位置」を見る
  • 行ける同点の候補の、優先順位を守る
つまずきやすいところ魚1を replace した途中の fish を魚2に渡すと、同点のときの入れかわり (0,1)→(1,0) が崩れます。また、流される前の d を使うと、最初の2匹が真ん中に集まりません。
少し変えてみよう

Lab の d を (2,0,2) に、fish の Fish(2,2) を Fish(2,1) に変えて、保存 → 終了 → 実行し直してみましょう。Space を1回押すと魚の位置は (1,0)、2回で (0,1) に戻ります。守られている数と同じなので、流されることはありません。自動の確認は python exercises/02_old_positions.py です。最後に、最初の値に戻します。

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

"""076 紙の水族館: 教材ステージ 2。架空の整数生態系。"""
STAGE = 2

from dataclasses import dataclass, replace

TICKS = 8
D_CAP = 12
FLOW = (1, 2, 1)

def _int(value):
    return isinstance(value, int) and not isinstance(value, bool)

def _vector(value, length, lo, hi):
    return (isinstance(value, tuple) and len(value) == length
            and all(_int(v) and lo <= v <= hi for v in value))

def _shelters(value):
    if not _vector(value, 3, 0, 2) or sum(value) != 2:
        raise ValueError("隠れ家は整数の3区画へ合計2チップ置いてください")

def drift(d, shelters):
    """Return (new counts, permitted (source,destination) moves, blocked moves).

    Receiving slots are frozen before any departure and reserved in L/M/R
    source order. This intentionally cannot reuse outgoing capacity this tick.
    """
    if not _vector(d, 3, 0, D_CAP):
        raise ValueError("daphnia must be a bounded integer triple")
    _shelters(shelters)
    slots = [D_CAP - value for value in d]
    moves, blocked = [], []
    for source, dest in enumerate(FLOW):
        if d[source] > 2 * shelters[source]:
            if slots[dest] > 0:
                moves.append((source, dest))
                slots[dest] -= 1
            else:
                blocked.append((source, dest))
    result = list(d)
    for source, dest in moves:
        result[source] -= 1
        result[dest] += 1
    return tuple(result), tuple(moves), tuple(blocked)

@dataclass(frozen=True)
class Fish:
    id: int
    zone: int
    meals: int = 0

    def __post_init__(self):
        if not _int(self.id) or self.id not in (1, 2):
            raise ValueError("fish id must be 1 or 2")
        if not _int(self.zone) or not 0 <= self.zone <= 2:
            raise ValueError("fish zone must be 0..2")
        if not _int(self.meals) or not 0 <= self.meals <= TICKS:
            raise ValueError("fish meals must be 0..8")

@dataclass(frozen=True)
class Food:
    id: int
    zone: int
    age: int = 0

    def __post_init__(self):
        if not _int(self.id) or self.id < 0:
            raise ValueError("food id must be a nonnegative integer")
        if not _int(self.zone) or not 0 <= self.zone <= 2:
            raise ValueError("food zone must be 0..2")
        if not _int(self.age) or self.age not in (0, 1):
            raise ValueError("retained food age must be 0 or 1")

def fish_destinations(fish, d, shelters, food):
    """Both fish plan against the same post-drift counts and OLD fish positions."""
    scores = tuple(2 * sum(f.zone == z for f in food) + max(0, d[z] - 2 * shelters[z]) for z in range(3))
    destinations = []
    for index, current in enumerate(fish):
        choices = tuple(z for z in range(3) if abs(z - current.zone) <= 1)
        best = max(scores[z] for z in choices)
        priority = (fish[1 - index].zone, current.zone, 0, 1, 2)
        destinations.append(next(z for z in priority if z in choices and scores[z] == best))
    return tuple(destinations), scores

@dataclass(frozen=True)
class Lab:
    d: tuple = (3, 0, 3)
    shelters: tuple = (1, 0, 1)
    fish: tuple = (Fish(1, 0), Fish(2, 2))
    t: int = 0
    moves: tuple = ()
    blocked: tuple = ()
    scores: tuple = (0, 0, 0)

    @property
    def ended(self):
        return self.t == TICKS

    @property
    def fish_positions(self):
        return tuple(f.zone for f in self.fish)


def initial():
    return Lab()


def advance(state):
    if state.ended:
        return state
    d, moves, blocked = drift(state.d, state.shelters)
    destinations, scores = fish_destinations(state.fish, d, state.shelters, ())
    fish = tuple(replace(f, zone=destinations[i]) for i, f in enumerate(state.fish))
    return replace(state, d=d, fish=fish, scores=scores,
                   t=state.t + 1, moves=moves, blocked=blocked)


# Teaching interface: independent of the original Controller.
from dataclasses import asdict

class App:
    def __init__(self):
        self.level = 'A'
        self.algae_enabled = True
        self.gated = False
        self.reset()

    def reset(self):
        self.state = initial(self.level, self.algae_enabled) if STAGE >= 3 else initial()
        self.cursor = 0
        self.amount = 0
        self.message = 'READY / SELECTING DOES NOT ADVANCE TIME'

    def press(self, key):
        if key == 'R':
            self.reset()
            return
        if STAGE >= 3 and key in ('1', '2', '3'):
            self.level = 'ABC'[int(key) - 1]
            self.reset()
            return
        if STAGE >= 4 and key == 'B':
            self.algae_enabled = not self.algae_enabled
            self.reset()
            return
        if STAGE == 5 and key == 'X':
            self.state = undo(self.state)
            self.message = 'UNDO / REPLAYED EARLIER INPUTS'
            return
        if STAGE >= 3 and key == 'TAB':
            if self.state.t:
                self.message = 'SHELTERS LOCKED AFTER TICK 0'
            else:
                index = SHELTER_LAYOUTS.index(self.state.shelters)
                self.state = edit_shelters(self.state, SHELTER_LAYOUTS[(index + 1) % len(SHELTER_LAYOUTS)])
                self.message = 'TWO SHELTER CHIPS / TAB CYCLES SIX LAYOUTS'
            return
        if key in ('LEFT', 'RIGHT'):
            self.cursor = (self.cursor + (1 if key == 'RIGHT' else -1)) % 3
        elif STAGE >= 3 and key in ('UP', 'DOWN'):
            self.amount = max(0, min(2, self.amount + (1 if key == 'UP' else -1)))
        elif key in ('SPACE', 'RETURN'):
            if self.state.ended:
                self.message = 'EIGHT TICKS FINISHED / R RESTARTS'
                return
            if STAGE < 3:
                self.state = advance(self.state)
            else:
                amount = self.amount if key == 'RETURN' else 0
                try:
                    self.state = step(self.state, self.cursor, amount)
                except ValueError:
                    self.message = 'NOT ENOUGH FOOD / TICK DID NOT ADVANCE'
                    return
            self.message = 'ONE TICK COMMITTED / RELEASE BEFORE NEXT PRESS'

    def control(self, pressed, held):
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
            return
        if len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True

    def prediction(self):
        if STAGE != 5 or self.state.ended:
            return None
        try:
            return forecast(self.state, self.cursor, self.amount)
        except ValueError:
            return None

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 12, 'PAPER AQUARIUM / STAGE ' + str(STAGE) + ' / ' + ('LAB' if STAGE < 3 else 'LEVEL ' + self.level), 10)
        p.text(12, 27, 'FICTIONAL INTEGER ECOSYSTEM / NOT AQUARIUM CARE ADVICE', 6)
        p.text(12, 45, 'TICK ' + str(s.t) + '/8  FLOW: L>M, M>R, R>M', 7)
        if STAGE >= 3:
            p.text(320, 45, 'FOOD LEFT ' + str(s.remaining_food) + '/10  MEALS ' + str(s.meals), 10)
        for z in range(3):
            x = 12 + z * 196
            p.rect(x, 68, 188, 216, 5)
            p.rect(x + 2, 70, 184, 203, 1)
            p.rectb(x, 68, 188, 216, 10 if z == self.cursor else 6)
            p.text(x + 8, 79, 'LMR'[z] + ' -> ' + 'MRM'[z], 10)
            p.text(x + 66, 79, 'D ' + str(s.d[z]) + ' / CAP 12', 11)
            for chip in range(s.shelters[z]):
                sx = x + 49 + chip * 72
                p.tri(sx - 21, 267, sx, 237, sx + 21, 267, 13)
                p.tri(sx - 10, 267, sx, 254, sx + 10, 267, 1)
            for i in range(s.d[z]):
                dx, dy = x + 21 + (i % 6) * 28, 194 + (i // 6) * 22
                p.circ(dx, dy, 3, 11)
                p.line(dx - 2, dy - 2, dx - 4, dy - 6, 7)
            if STAGE >= 2:
                for f in s.fish:
                    if f.zone != z:
                        continue
                    fx, fy = x + 46 + (f.id - 1) * 70, 142 + (f.id - 1) * 17
                    color = 10 if f.id == 1 else 14
                    p.tri(fx + 10, fy, fx - 8, fy - 8, fx - 8, fy + 8, color)
                    p.tri(fx - 7, fy, fx - 19, fy - 7, fx - 19, fy + 7, color)
                    p.pset(fx + 4, fy - 2, 0)
                    p.text(fx - 4, fy - 19, 'F' + str(f.id), color)
            if STAGE >= 3:
                particles = [f for f in s.food if f.zone == z]
                for i, f in enumerate(particles):
                    p.rect(x + 15 + (i % 8) * 20, 101 + (i // 8) * 9, 4, 4, 9 if f.age else 10)
                p.text(x + 8, 290, 'SHELTER ' + str(s.shelters[z]) + '  ALGAE ' + str(s.algae[z]), 6)
                p.text(x + 8, 302, 'FOOD ' + str(len(particles)) + '  PROTECTED ' + str(2 * s.shelters[z]), 7)
            else:
                p.text(x + 8, 290, 'SHELTER ' + str(s.shelters[z]) + '  PROTECTED ' + str(2 * s.shelters[z]), 6)
        if STAGE < 3:
            p.text(12, 325, 'DRIFT MOVES ' + str(s.moves) + ' / BLOCKED ' + str(s.blocked), 7)
            p.text(12, 341, 'CONSERVES TOTAL D = ' + str(sum(s.d)) + ' / NO MEALS OR BIRTHS', 11)
            if STAGE == 2:
                p.text(12, 357, 'POST-DRIFT SCORES ' + str(s.scores) + ' / BOTH USE OLD FISH POSITIONS' if s.t else 'NO FISH PLAN YET / SPACE TO COMPUTE THE FIRST TICK', 7)
        else:
            p.text(12, 325, 'NEXT INPUT: ' + 'LMR'[self.cursor] + ' / ' + str(self.amount) + ' PARTICLES', 10)
            if s.history:
                last = s.history[-1]
                p.text(12, 341, 'SCORES ' + str(last.scores) + ' / MEAL ORDER ' + str(last.meal_order), 7)
                kinds = ','.join(e.kind for e in s.events if e.kind in ('algae','birth','sink','eat_d','algae_full'))
                p.text(12, 357, 'EVENTS: ' + (kinds or 'feed / drift / meals')[:84], 11)
            if STAGE == 3:
                p.text(12, 377, 'STAGE 03: FOOD DOES NOT AGE / NO ALGAE CONVERSION OR BIRTHS', 6)
            else:
                p.text(12, 377, 'FOOD -> ALGAE: ' + ('ON' if s.algae_enabled else 'OFF') + ' / B RESTARTS COMPARISON', 6)
            if STAGE == 5:
                pred = self.prediction()
                if pred:
                    p.text(12, 396, 'NEXT TICK ONLY: FISH ' + str(pred.fish_positions) + ' MEALS ' + str(pred.meals) + ' D ' + str(pred.d), 10)
                else:
                    p.text(12, 396, 'NO NEXT FORECAST: FINISHED OR INSUFFICIENT FOOD', 8)
            if s.ended:
                p.text(12, 415, 'SUCCESS: BOTH MEALS >=3 AND TOTAL D >=2' if success(s) else 'FINISHED: CHECK MEALS >=3 EACH AND TOTAL D >=2', 11 if success(s) else 8)
        p.text(12, 440, self.message, 10)
        p.text(12, 461, 'ENTER COMMIT / SPACE WAIT / R RESET / ESC QUIT / RELEASE EACH KEY', 7)
        if STAGE >= 3:
            p.text(12, 477, 'LEFT/RIGHT ZONE / UP/DOWN 0-2 FOOD / TAB SHELTERS AT T0 / 1-3 LEVEL', 6)
        if STAGE == 5:
            p.text(12, 493, 'X UNDO BY REPLAY / R DOES NOT RELOAD CODE', 6)

def demo_keys():
    if STAGE < 3:
        return ('SPACE',)
    return ('RIGHT', 'UP', 'UP', 'RETURN', 'RIGHT', 'RETURN', 'LEFT', 'LEFT', 'RETURN', 'SPACE')

def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    import json
    from pathlib import Path
    p.init(612, 516, title='Paper aquarium lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
    app = App()
    sequence = demo_keys() if demo else ()
    keynames = ('LEFT','RIGHT','UP','DOWN','RETURN','SPACE','TAB','X','R','1','2','3','B')
    keys = {k: getattr(p, 'KEY_' + k) for k in keynames}
    limit = max(8, len(sequence) * 2 + 6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            if state_json:
                Path(state_json).write_text(json.dumps({'stage': STAGE, 'state': asdict(app.state), 'scope': 'Real Pyxel; synthetic controls, not physical keyboard or human playtest'}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            p.quit()
        if headless:
            i = p.frame_count // 2
            pressed = {sequence[i]} if demo and p.frame_count % 2 == 0 and i < 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
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone paper aquarium teaching stage')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    # Freeze the caller cwd BEFORE importing or initializing Pyxel.
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value:
            target = Path(value).resolve()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)
STEP 03 / 05

十粒の餌と交代の食事

限られた資源・入力の確認・順番のある取り合い。

ここから、元のゲームの Level、State、Food、TickReport を使います。型と確認が増えるので、まず State の fish・d・food・remaining_food・t を追い、次に history を読みましょう。01/02 の小さな Lab を、正式な1回の挑戦の記録に置きかえた段階です。1/2/3 で水槽 A/B/C に切り替え、始める前の Tab で、合わせて2つの隠れ家の6通りの置き方を順に選びます。始めたあとは編集できません。最初の表示は状況 A で、魚は2匹とも左、守られたミジンコが4匹、餌は10粒です。

左右キーで区画を、上下キーで0/1/2粒を選びます。選ぶだけでは、時刻も餌の残りも変わりません。Enter はその粒数を置いて1刻進め、Space は、選んだ数に関係なく、餌なしで進めます。zone や amount がおかしな値、bool、餌の残りを超える場合は ValueError です。足りないときは、画面が英語の短いメッセージを出し、刻を進めません。餌なしの入力では記録する区画を0にそろえるので、左右のどこで待っても同じ記録です。

処理の順番は、餌やり → 流される → 2匹の同時の計画 → 食事、です。食べる順番は、1刻目が魚1・魚2、2刻目は魚2・魚1、そのあとも交代です。1匹は1刻に1回だけ食べます。餌があれば、年齢の大きい粒から、同じ年齢なら id の小さい粒から選びます。餌がなければ、守られている数を超えたミジンコを1匹だけ食べ、それもなければおなかがすいたままです。1匹が食べた分は、2匹目には残りません。

この段階で簡単にしているのは、残った餌の年齢を進めず、消える・藻になる・誕生、を行わないことです。最初の状況 C の藻は表示されても使われません。Food は年齢0/1を持てて、選ぶ関数は古い順ですが、ふつうの新しい餌は0のままです。完成版の「2刻で消える」とは違います。8刻・10粒・魚それぞれ3回以上の食事・ミジンコの合計2匹以上、という達成の条件を加えます。判定は8刻目だけです。途中で目標の数に届いても、完了にはなりません。

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

03_meals.py / 注目する部分
    order = (0, 1) if t % 2 else (1, 0)
    gains = [0, 0]
    first_ate_zone = -1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_meals.py
macOS / Linux
.venv/bin/python 03_meals.py
画面で見ること状況 A で上を1回 → Enter と押します。1刻目は MEALS=(1,0)、FOOD LEFT=9 です。離してから Enter でもう1粒置くと、2刻目は MEALS=(1,1)、FOOD LEFT=8 です。4匹のミジンコは守られています。
  • 選ぶことと、確定することを分ける
  • 餌が足りないときは、1刻も進まない
  • 奇数の刻と偶数の刻の、食べる順番を読む
つまずきやすいところ食べる順番を毎刻魚1からにすると、1粒ずつの餌が片方にかたよってしまいます。入力を確かめる前に t や餌の残りを更新すると、断ったはずの操作で状態が変わってしまいます。
少し変えてみよう

step の order = (0, 1) if t % 2 else (1, 0) を、order = (1, 0) if t % 2 else (0, 1) に1行変えてみましょう。保存 → 終了 → 実行し直します。状況 A で上 → Enter なら MEALS=(0,1)、2回目の Enter で (1,1) です。python exercises/03_meal_order.py で、変えた版を実行できます。確認したら、元のゲームの順番に戻します。

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

"""076 紙の水族館: 教材ステージ 3。架空の整数生態系。"""
STAGE = 3

"""076 紙の水族館: a pure, deterministic, immutable integer toy ecosystem.

Order: legal feeding -> tick -> simultaneous drift -> simultaneous fish plans
-> alternating meals -> food aging/algae -> births -> eight-tick result.
The comparison switch changes ONLY whether expired food becomes algae.
"""
from dataclasses import asdict, dataclass, replace
import hashlib
import json

VERSION = "076-v1"
TICKS = 8
FOOD_BUDGET = 10
D_CAP = 12
ALGAE_CAP = 3
ZONE_NAMES = ("左", "中", "右")
FLOW = (1, 2, 1)
ACTIONS = ((0, 0),) + tuple((z, n) for z in range(3) for n in (1, 2))
SHELTER_LAYOUTS = tuple((a, b, 2 - a - b) for a in range(3) for b in range(3 - a))
Triple = tuple[int, int, int]


def _int(value):
    return isinstance(value, int) and not isinstance(value, bool)


def _vector(value, length, lo, hi):
    return (isinstance(value, tuple) and len(value) == length
            and all(_int(v) and lo <= v <= hi for v in value))


def _shelters(value):
    if not _vector(value, 3, 0, 2) or sum(value) != 2:
        raise ValueError("隠れ家は整数の3区画へ合計2チップ置いてください")


@dataclass(frozen=True)
class Level:
    key: str
    title: str
    initial_fish: tuple[int, int]
    initial_d: Triple
    initial_algae: Triple
    initial_shelters: Triple
    ticks: int = TICKS
    food_budget: int = FOOD_BUDGET
    target_meals: int = 3
    target_d: int = 2

    def __post_init__(self):
        if not self.key or not self.title:
            raise ValueError("level needs a key and title")
        if not _vector(self.initial_fish, 2, 0, 2):
            raise ValueError("two initial fish zones required")
        if not _vector(self.initial_d, 3, 0, D_CAP):
            raise ValueError("invalid initial daphnia")
        if not _vector(self.initial_algae, 3, 0, ALGAE_CAP):
            raise ValueError("invalid initial algae")
        _shelters(self.initial_shelters)
        if (self.ticks, self.food_budget, self.target_meals, self.target_d) != (8, 10, 3, 2):
            raise ValueError("the fixed goal is 8 ticks, 10 food, 3 meals each and 2 daphnia")

    @property
    def id(self):
        return self.key

    @property
    def public_id(self):
        return f"076-{self.key}-v1"


LEVELS = {
    "A": Level("A", "左の岩陰", (0, 0), (4, 0, 0), (0, 0, 0), (2, 0, 0)),
    "B": Level("B", "ふたつの住処", (0, 2), (3, 0, 3), (0, 0, 0), (1, 0, 1)),
    "C": Level("C", "緑のある水槽", (1, 1), (2, 1, 2), (2, 0, 1), (1, 0, 1)),
}


@dataclass(frozen=True)
class Fish:
    id: int
    zone: int
    meals: int = 0

    def __post_init__(self):
        if not _int(self.id) or self.id not in (1, 2):
            raise ValueError("fish id must be 1 or 2")
        if not _int(self.zone) or not 0 <= self.zone <= 2:
            raise ValueError("fish zone must be 0..2")
        if not _int(self.meals) or not 0 <= self.meals <= TICKS:
            raise ValueError("fish meals must be 0..8")


@dataclass(frozen=True)
class Food:
    id: int
    zone: int
    age: int = 0

    def __post_init__(self):
        if not _int(self.id) or self.id < 0:
            raise ValueError("food id must be a nonnegative integer")
        if not _int(self.zone) or not 0 <= self.zone <= 2:
            raise ValueError("food zone must be 0..2")
        if not _int(self.age) or self.age not in (0, 1):
            raise ValueError("retained food age must be 0 or 1")


@dataclass(frozen=True)
class Event:
    t: int
    kind: str
    zone: int = -1
    source: int = -1
    fish_id: int = 0
    food_id: int = -1
    amount: int = 0
    text: str = ""


@dataclass(frozen=True)
class TickReport:
    t: int
    zone: int
    amount: int
    before_d: Triple
    drift_d: Triple
    final_d: Triple
    before_algae: Triple
    final_algae: Triple
    before_fish: tuple[int, int]
    destinations: tuple[int, int]
    scores: Triple
    meal_order: tuple[int, int]
    meal_gains: tuple[int, int]
    final_meals: tuple[int, int]
    grouped: bool
    events: tuple[Event, ...]


@dataclass(frozen=True)
class State:
    level_id: str
    algae_enabled: bool
    shelters: Triple
    fish: tuple[Fish, Fish]
    d: Triple
    algae: Triple
    t: int = 0
    food: tuple[Food, ...] = ()
    remaining_food: int = FOOD_BUDGET
    next_food_id: int = 0
    events: tuple[Event, ...] = ()
    history: tuple[TickReport, ...] = ()

    def __post_init__(self):
        if self.level_id not in LEVELS or type(self.algae_enabled) is not bool:
            raise ValueError("unknown level or non-boolean algae setting")
        _shelters(self.shelters)
        if not _int(self.t) or not 0 <= self.t <= TICKS:
            raise ValueError("tick must be an integer in 0..8")
        if not isinstance(self.fish, tuple) or len(self.fish) != 2 or any(not isinstance(f, Fish) for f in self.fish) or tuple(f.id for f in self.fish) != (1, 2):
            raise ValueError("fish must be the immutable pair with ids 1, 2")
        if any(f.meals > self.t for f in self.fish):
            raise ValueError("a fish cannot eat more than once per elapsed tick")
        if not _vector(self.d, 3, 0, D_CAP) or not _vector(self.algae, 3, 0, ALGAE_CAP):
            raise ValueError("daphnia/algae must be bounded integer triples")
        if not isinstance(self.food, tuple) or any(not isinstance(f, Food) for f in self.food):
            raise ValueError("food must be an immutable tuple")
        if len({f.id for f in self.food}) != len(self.food):
            raise ValueError("food ids must be unique")
        if not _int(self.remaining_food) or not 0 <= self.remaining_food <= FOOD_BUDGET:
            raise ValueError("remaining food must be 0..10")
        if not _int(self.next_food_id) or not 0 <= self.next_food_id <= FOOD_BUDGET:
            raise ValueError("next food id must be 0..10")
        if self.next_food_id + self.remaining_food != FOOD_BUDGET:
            raise ValueError("food ids and remaining budget must account for ten particles")
        if any(f.id >= self.next_food_id for f in self.food):
            raise ValueError("retained food must already have been issued")
        if not isinstance(self.events, tuple) or any(not isinstance(e, Event) for e in self.events):
            raise ValueError("events must be an immutable tuple")
        if not isinstance(self.history, tuple) or any(not isinstance(r, TickReport) for r in self.history):
            raise ValueError("history must be an immutable tuple")

    @property
    def level(self):
        return LEVELS[self.level_id]

    @property
    def public_id(self):
        return self.level.public_id + ("" if self.algae_enabled else "-no-food-algae")

    @property
    def ended(self):
        return self.t == TICKS

    @property
    def fish_positions(self):
        return tuple(f.zone for f in self.fish)

    @property
    def meals(self):
        return tuple(f.meals for f in self.fish)

    @property
    def inputs(self):
        return tuple((r.zone, r.amount) for r in self.history)

    @property
    def food_used(self):
        return FOOD_BUDGET - self.remaining_food

    @property
    def births(self):
        return sum(e.amount for r in self.history for e in r.events if e.kind == "birth")

    @property
    def algae_events(self):
        return sum(e.amount for r in self.history for e in r.events if e.kind == "algae")

    @property
    def grouped_ticks(self):
        return sum(r.grouped for r in self.history)

    @property
    def a(self):
        return self.algae

    @property
    def log(self):
        return self.history


def initial(level_id="A", algae_enabled=True, shelters=None):
    if level_id not in LEVELS:
        raise ValueError("水槽はA/B/Cから選んでください")
    level = LEVELS[level_id]
    shelters = level.initial_shelters if shelters is None else tuple(shelters)
    return State(level_id, algae_enabled, shelters,
                 tuple(Fish(i + 1, z) for i, z in enumerate(level.initial_fish)),
                 level.initial_d, level.initial_algae)


def edit_shelters(state, shelters):
    """Pre-trial editing only; callers retain the old immutable state for undo."""
    if state.t != 0:
        raise ValueError("隠れ家は開始前だけ動かせます")
    return replace(state, shelters=tuple(shelters))


def drift(d, shelters):
    """Return (new counts, permitted (source,destination) moves, blocked moves).

    Receiving slots are frozen before any departure and reserved in L/M/R
    source order. This intentionally cannot reuse outgoing capacity this tick.
    """
    if not _vector(d, 3, 0, D_CAP):
        raise ValueError("daphnia must be a bounded integer triple")
    _shelters(shelters)
    slots = [D_CAP - value for value in d]
    moves, blocked = [], []
    for source, dest in enumerate(FLOW):
        if d[source] > 2 * shelters[source]:
            if slots[dest] > 0:
                moves.append((source, dest))
                slots[dest] -= 1
            else:
                blocked.append((source, dest))
    result = list(d)
    for source, dest in moves:
        result[source] -= 1
        result[dest] += 1
    return tuple(result), tuple(moves), tuple(blocked)


def fish_destinations(fish, d, shelters, food):
    """Both fish plan against the same post-drift counts and OLD fish positions."""
    scores = tuple(2 * sum(f.zone == z for f in food) + max(0, d[z] - 2 * shelters[z]) for z in range(3))
    destinations = []
    for index, current in enumerate(fish):
        choices = tuple(z for z in range(3) if abs(z - current.zone) <= 1)
        best = max(scores[z] for z in choices)
        priority = (fish[1 - index].zone, current.zone, 0, 1, 2)
        destinations.append(next(z for z in priority if z in choices and scores[z] == best))
    return tuple(destinations), scores


def success(state):
    return state.ended and min(state.meals) >= 3 and sum(state.d) >= 2


def step(state, zone, amount):
    """Commit one feeding action; invalid input raises without changing state.

    Valid steps after the eighth tick return the identical finished state.
    Zone is still validated for a wait, then canonicalized to zero in its log.
    """
    if not _int(zone) or zone not in range(3):
        raise ValueError("給餌区画は左/中/右から選んでください")
    if not _int(amount) or amount not in (0, 1, 2):
        raise ValueError("餌は整数の0/1/2粒です")
    if state.ended:
        return state
    if amount > state.remaining_food:
        raise ValueError("残りの餌が足りません。この刻は進みません")
    if amount == 0:
        zone = 0
    t = state.t + 1
    events = []

    def emit(kind, z=-1, source=-1, fish_id=0, food_id=-1, count=0, text=""):
        events.append(Event(t, kind, z, source, fish_id, food_id, count, text))

    food = list(state.food)
    for food_id in range(state.next_food_id, state.next_food_id + amount):
        food.append(Food(food_id, zone))
        emit("feed", zone, food_id=food_id, count=1, text=f"{ZONE_NAMES[zone]}へ一粒")
    drift_d, moves, blocked = drift(state.d, state.shelters)
    for source, dest in moves:
        emit("drift", dest, source, count=1, text=f"{ZONE_NAMES[source]}→{ZONE_NAMES[dest]}へ漂流")
    for source, dest in blocked:
        emit("drift_blocked", dest, source, text="行先が刻の初めに満員なので残る")
    destinations, scores = fish_destinations(state.fish, drift_d, state.shelters, food)
    fish = [replace(f, zone=destinations[i]) for i, f in enumerate(state.fish)]
    for old, current in zip(state.fish, fish):
        if old.zone != current.zone:
            emit("move", current.zone, old.zone, current.id, text=f"魚{current.id}が{ZONE_NAMES[current.zone]}へ")
    grouped = destinations[0] == destinations[1]
    if grouped:
        emit("school", destinations[0], text="二匹が同じ区画に集まる")
    d = list(drift_d)
    algae = list(state.algae)
    order = (0, 1) if t % 2 else (1, 0)
    gains = [0, 0]
    first_ate_zone = -1
    for index in order:
        current = fish[index]
        z = current.zone
        if first_ate_zone == z:
            emit("competition", z, fish_id=current.id,
                 text=f"魚{fish[order[0]].id}が先に食事。魚{current.id}は残った分を確かめる")
        particles = [f for f in food if f.zone == z]
        if particles:
            oldest = min(particles, key=lambda f: (-f.age, f.id))
            food.remove(oldest)
            gains[index] = 1
            emit("eat_food", z, fish_id=current.id, food_id=oldest.id, count=1,
                 text=f"魚{current.id}が古い餌から一粒食べる")
        elif d[z] > 2 * state.shelters[z]:
            d[z] -= 1
            gains[index] = 1
            emit("eat_d", z, fish_id=current.id, count=1,
                 text=f"魚{current.id}が隠れ家の外のミジンコを一匹食べる")
        else:
            emit("hungry", z, fish_id=current.id,
                 text=f"魚{current.id}は食事なし。守られたミジンコは食べない")
        if gains[index]:
            fish[index] = replace(current, meals=current.meals + 1)
            if index == order[0]:
                first_ate_zone = z
    remaining = food  # Stage 03: retained particles do not age yet.
    final_fish = tuple(fish)
    if t == TICKS:
        fulfilled = min(f.meals for f in final_fish) >= 3 and sum(d) >= 2
        emit("finish", text="基本の世話を達成" if fulfilled else "8刻の観察終了。食事と残った数を確認")
    record = TickReport(t, zone, amount, state.d, drift_d, tuple(d), state.algae,
                        tuple(algae), state.fish_positions, destinations, scores,
                        tuple(i + 1 for i in order), tuple(gains),
                        tuple(f.meals for f in final_fish), grouped, tuple(events))
    return State(state.level_id, state.algae_enabled, state.shelters, final_fish,
                 tuple(d), tuple(algae), t, tuple(remaining),
                 state.remaining_food - amount, state.next_food_id + amount,
                 tuple(events), state.history + (record,))


# Teaching interface: independent of the original Controller.
from dataclasses import asdict

class App:
    def __init__(self):
        self.level = 'A'
        self.algae_enabled = True
        self.gated = False
        self.reset()

    def reset(self):
        self.state = initial(self.level, self.algae_enabled) if STAGE >= 3 else initial()
        self.cursor = 0
        self.amount = 0
        self.message = 'READY / SELECTING DOES NOT ADVANCE TIME'

    def press(self, key):
        if key == 'R':
            self.reset()
            return
        if STAGE >= 3 and key in ('1', '2', '3'):
            self.level = 'ABC'[int(key) - 1]
            self.reset()
            return
        if STAGE >= 4 and key == 'B':
            self.algae_enabled = not self.algae_enabled
            self.reset()
            return
        if STAGE == 5 and key == 'X':
            self.state = undo(self.state)
            self.message = 'UNDO / REPLAYED EARLIER INPUTS'
            return
        if STAGE >= 3 and key == 'TAB':
            if self.state.t:
                self.message = 'SHELTERS LOCKED AFTER TICK 0'
            else:
                index = SHELTER_LAYOUTS.index(self.state.shelters)
                self.state = edit_shelters(self.state, SHELTER_LAYOUTS[(index + 1) % len(SHELTER_LAYOUTS)])
                self.message = 'TWO SHELTER CHIPS / TAB CYCLES SIX LAYOUTS'
            return
        if key in ('LEFT', 'RIGHT'):
            self.cursor = (self.cursor + (1 if key == 'RIGHT' else -1)) % 3
        elif STAGE >= 3 and key in ('UP', 'DOWN'):
            self.amount = max(0, min(2, self.amount + (1 if key == 'UP' else -1)))
        elif key in ('SPACE', 'RETURN'):
            if self.state.ended:
                self.message = 'EIGHT TICKS FINISHED / R RESTARTS'
                return
            if STAGE < 3:
                self.state = advance(self.state)
            else:
                amount = self.amount if key == 'RETURN' else 0
                try:
                    self.state = step(self.state, self.cursor, amount)
                except ValueError:
                    self.message = 'NOT ENOUGH FOOD / TICK DID NOT ADVANCE'
                    return
            self.message = 'ONE TICK COMMITTED / RELEASE BEFORE NEXT PRESS'

    def control(self, pressed, held):
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
            return
        if len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True

    def prediction(self):
        if STAGE != 5 or self.state.ended:
            return None
        try:
            return forecast(self.state, self.cursor, self.amount)
        except ValueError:
            return None

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 12, 'PAPER AQUARIUM / STAGE ' + str(STAGE) + ' / ' + ('LAB' if STAGE < 3 else 'LEVEL ' + self.level), 10)
        p.text(12, 27, 'FICTIONAL INTEGER ECOSYSTEM / NOT AQUARIUM CARE ADVICE', 6)
        p.text(12, 45, 'TICK ' + str(s.t) + '/8  FLOW: L>M, M>R, R>M', 7)
        if STAGE >= 3:
            p.text(320, 45, 'FOOD LEFT ' + str(s.remaining_food) + '/10  MEALS ' + str(s.meals), 10)
        for z in range(3):
            x = 12 + z * 196
            p.rect(x, 68, 188, 216, 5)
            p.rect(x + 2, 70, 184, 203, 1)
            p.rectb(x, 68, 188, 216, 10 if z == self.cursor else 6)
            p.text(x + 8, 79, 'LMR'[z] + ' -> ' + 'MRM'[z], 10)
            p.text(x + 66, 79, 'D ' + str(s.d[z]) + ' / CAP 12', 11)
            for chip in range(s.shelters[z]):
                sx = x + 49 + chip * 72
                p.tri(sx - 21, 267, sx, 237, sx + 21, 267, 13)
                p.tri(sx - 10, 267, sx, 254, sx + 10, 267, 1)
            for i in range(s.d[z]):
                dx, dy = x + 21 + (i % 6) * 28, 194 + (i // 6) * 22
                p.circ(dx, dy, 3, 11)
                p.line(dx - 2, dy - 2, dx - 4, dy - 6, 7)
            if STAGE >= 2:
                for f in s.fish:
                    if f.zone != z:
                        continue
                    fx, fy = x + 46 + (f.id - 1) * 70, 142 + (f.id - 1) * 17
                    color = 10 if f.id == 1 else 14
                    p.tri(fx + 10, fy, fx - 8, fy - 8, fx - 8, fy + 8, color)
                    p.tri(fx - 7, fy, fx - 19, fy - 7, fx - 19, fy + 7, color)
                    p.pset(fx + 4, fy - 2, 0)
                    p.text(fx - 4, fy - 19, 'F' + str(f.id), color)
            if STAGE >= 3:
                particles = [f for f in s.food if f.zone == z]
                for i, f in enumerate(particles):
                    p.rect(x + 15 + (i % 8) * 20, 101 + (i // 8) * 9, 4, 4, 9 if f.age else 10)
                p.text(x + 8, 290, 'SHELTER ' + str(s.shelters[z]) + '  ALGAE ' + str(s.algae[z]), 6)
                p.text(x + 8, 302, 'FOOD ' + str(len(particles)) + '  PROTECTED ' + str(2 * s.shelters[z]), 7)
            else:
                p.text(x + 8, 290, 'SHELTER ' + str(s.shelters[z]) + '  PROTECTED ' + str(2 * s.shelters[z]), 6)
        if STAGE < 3:
            p.text(12, 325, 'DRIFT MOVES ' + str(s.moves) + ' / BLOCKED ' + str(s.blocked), 7)
            p.text(12, 341, 'CONSERVES TOTAL D = ' + str(sum(s.d)) + ' / NO MEALS OR BIRTHS', 11)
            if STAGE == 2:
                p.text(12, 357, 'POST-DRIFT SCORES ' + str(s.scores) + ' / BOTH USE OLD FISH POSITIONS' if s.t else 'NO FISH PLAN YET / SPACE TO COMPUTE THE FIRST TICK', 7)
        else:
            p.text(12, 325, 'NEXT INPUT: ' + 'LMR'[self.cursor] + ' / ' + str(self.amount) + ' PARTICLES', 10)
            if s.history:
                last = s.history[-1]
                p.text(12, 341, 'SCORES ' + str(last.scores) + ' / MEAL ORDER ' + str(last.meal_order), 7)
                kinds = ','.join(e.kind for e in s.events if e.kind in ('algae','birth','sink','eat_d','algae_full'))
                p.text(12, 357, 'EVENTS: ' + (kinds or 'feed / drift / meals')[:84], 11)
            if STAGE == 3:
                p.text(12, 377, 'STAGE 03: FOOD DOES NOT AGE / NO ALGAE CONVERSION OR BIRTHS', 6)
            else:
                p.text(12, 377, 'FOOD -> ALGAE: ' + ('ON' if s.algae_enabled else 'OFF') + ' / B RESTARTS COMPARISON', 6)
            if STAGE == 5:
                pred = self.prediction()
                if pred:
                    p.text(12, 396, 'NEXT TICK ONLY: FISH ' + str(pred.fish_positions) + ' MEALS ' + str(pred.meals) + ' D ' + str(pred.d), 10)
                else:
                    p.text(12, 396, 'NO NEXT FORECAST: FINISHED OR INSUFFICIENT FOOD', 8)
            if s.ended:
                p.text(12, 415, 'SUCCESS: BOTH MEALS >=3 AND TOTAL D >=2' if success(s) else 'FINISHED: CHECK MEALS >=3 EACH AND TOTAL D >=2', 11 if success(s) else 8)
        p.text(12, 440, self.message, 10)
        p.text(12, 461, 'ENTER COMMIT / SPACE WAIT / R RESET / ESC QUIT / RELEASE EACH KEY', 7)
        if STAGE >= 3:
            p.text(12, 477, 'LEFT/RIGHT ZONE / UP/DOWN 0-2 FOOD / TAB SHELTERS AT T0 / 1-3 LEVEL', 6)
        if STAGE == 5:
            p.text(12, 493, 'X UNDO BY REPLAY / R DOES NOT RELOAD CODE', 6)

def demo_keys():
    if STAGE < 3:
        return ('SPACE',)
    return ('RIGHT', 'UP', 'UP', 'RETURN', 'RIGHT', 'RETURN', 'LEFT', 'LEFT', 'RETURN', 'SPACE')

def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    import json
    from pathlib import Path
    p.init(612, 516, title='Paper aquarium lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
    app = App()
    sequence = demo_keys() if demo else ()
    keynames = ('LEFT','RIGHT','UP','DOWN','RETURN','SPACE','TAB','X','R','1','2','3','B')
    keys = {k: getattr(p, 'KEY_' + k) for k in keynames}
    limit = max(8, len(sequence) * 2 + 6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            if state_json:
                Path(state_json).write_text(json.dumps({'stage': STAGE, 'state': asdict(app.state), 'scope': 'Real Pyxel; synthetic controls, not physical keyboard or human playtest'}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            p.quit()
        if headless:
            i = p.frame_count // 2
            pressed = {sequence[i]} if demo and p.frame_count % 2 == 0 and i < 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
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone paper aquarium teaching stage')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    # Freeze the caller cwd BEFORE importing or initializing Pyxel.
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value:
            target = Path(value).resolve()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)
STEP 04 / 05

残した一粒を、藻と出生へつなぐ

年齢の境目・変わることと使うことの順番・1つの条件だけを変える比較。

食事が終わったあとの餌だけ、年齢を1つ増やします。0の粒は1になって残り、1の粒は2になる境目で消えます。Food.age に残るのは0/1だけです。消える粒の区画に隠れ家があって、藻になる設定が有効なら、藻を1つ増やします。藻の上限は区画ごとに3です。いっぱいなら、粒は消えるだけで、algae_full を記録します。隠れ家がないか、比較モードが OFF なら sink(沈む)です。

次に、3つの区画を見て、藻が1つ以上あり、ミジンコが1匹以上12匹未満なら、藻を1つ使ってミジンコを1匹増やします。1刻に1区画で生まれるのは、最大1匹です。0匹から自然に生まれることはなく、12匹の区画では藻を使いません。新しく生まれたミジンコは、その刻の「流される」や「食事」には戻りません。次の刻に初めて流されたり、食べられたりします。

「古い粒が藻になる → その藻を使って誕生」という順番なので、藻になったばかりの藻が、同じ刻に使われることがあります。画面の ALGAE が0でも、EVENTS に algae,birth が出て D が増えていれば、矛盾ではありません。直前の最後の値だけでなく、出来事も読む理由です。藻の上限3の判定は、誕生より前です。誕生で空く予定の分を使って、古い粒を余分に藻にしてはいけません。

B キーは、挑戦を最初からにして、残った餌が藻になる効果だけを ON/OFF します。年齢、粒が消えること、流されること、魚、食事、誕生の条件は同じです。OFF でも、水槽 C の最初の藻からは生まれます。状況 A で、M に2粒、R に2粒、L に2粒、待つ、と進むと、4刻目に algae,algae,birth が起き、左の D は5、藻は1です。OFF で同じ入力をしても、藻にもならず、生まれもしません。この比較は「直接餌をやるだけでは達成できない」という主張ではありません。M に2粒を5刻、3刻待つ方法でも、どの水槽でも達成できます。

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

04_algae_births.py / 注目する部分
    for z in range(3):
        if algae[z] > 0 and 0 < d[z] < D_CAP:
            algae[z] -= 1
            d[z] += 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_algae_births.py
macOS / Linux
.venv/bin/python 04_algae_births.py
画面で見ること状況 A で、右 → 上2回 → Enter、右 → Enter、左2回 → Enter、Space と押します。t4 で D=(5,0,0)、ALGAE=(1,0,0)、MEALS=(2,2)、残りの餌0です。B で OFF にして同じ入力をすると、生まれるのは0です。
  • 年齢2になる境目で消える
  • 藻の上限は、誕生より前に判定する
  • OFF でも、最初からある藻は誕生に使う
つまずきやすいところ置いたばかりの粒を年齢2として消さないでください。また、誕生を食事より前に動かすと、その刻に生まれたミジンコがすぐ食べられる、別のルールになってしまいます。
少し変えてみよう

残った餌が藻になる条件 if algae[z] < ALGAE_CAP: を if algae[z] < 1: に変えて、保存 → 終了 → 実行し直してみましょう。同じ4刻の入力で algae_full が1件増え、t4 の D は5、藻は0です。2粒目は上限で消え、1粒目の藻は誕生に使われます。python exercises/04_algae_cap.py で、変えた版を確かめられます。最後に ALGAE_CAP に戻します。

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

"""076 紙の水族館: 教材ステージ 4。架空の整数生態系。"""
STAGE = 4

"""076 紙の水族館: a pure, deterministic, immutable integer toy ecosystem.

Order: legal feeding -> tick -> simultaneous drift -> simultaneous fish plans
-> alternating meals -> food aging/algae -> births -> eight-tick result.
The comparison switch changes ONLY whether expired food becomes algae.
"""
from dataclasses import asdict, dataclass, replace
import hashlib
import json

VERSION = "076-v1"
TICKS = 8
FOOD_BUDGET = 10
D_CAP = 12
ALGAE_CAP = 3
ZONE_NAMES = ("左", "中", "右")
FLOW = (1, 2, 1)
ACTIONS = ((0, 0),) + tuple((z, n) for z in range(3) for n in (1, 2))
SHELTER_LAYOUTS = tuple((a, b, 2 - a - b) for a in range(3) for b in range(3 - a))
Triple = tuple[int, int, int]


def _int(value):
    return isinstance(value, int) and not isinstance(value, bool)


def _vector(value, length, lo, hi):
    return (isinstance(value, tuple) and len(value) == length
            and all(_int(v) and lo <= v <= hi for v in value))


def _shelters(value):
    if not _vector(value, 3, 0, 2) or sum(value) != 2:
        raise ValueError("隠れ家は整数の3区画へ合計2チップ置いてください")


@dataclass(frozen=True)
class Level:
    key: str
    title: str
    initial_fish: tuple[int, int]
    initial_d: Triple
    initial_algae: Triple
    initial_shelters: Triple
    ticks: int = TICKS
    food_budget: int = FOOD_BUDGET
    target_meals: int = 3
    target_d: int = 2

    def __post_init__(self):
        if not self.key or not self.title:
            raise ValueError("level needs a key and title")
        if not _vector(self.initial_fish, 2, 0, 2):
            raise ValueError("two initial fish zones required")
        if not _vector(self.initial_d, 3, 0, D_CAP):
            raise ValueError("invalid initial daphnia")
        if not _vector(self.initial_algae, 3, 0, ALGAE_CAP):
            raise ValueError("invalid initial algae")
        _shelters(self.initial_shelters)
        if (self.ticks, self.food_budget, self.target_meals, self.target_d) != (8, 10, 3, 2):
            raise ValueError("the fixed goal is 8 ticks, 10 food, 3 meals each and 2 daphnia")

    @property
    def id(self):
        return self.key

    @property
    def public_id(self):
        return f"076-{self.key}-v1"


LEVELS = {
    "A": Level("A", "左の岩陰", (0, 0), (4, 0, 0), (0, 0, 0), (2, 0, 0)),
    "B": Level("B", "ふたつの住処", (0, 2), (3, 0, 3), (0, 0, 0), (1, 0, 1)),
    "C": Level("C", "緑のある水槽", (1, 1), (2, 1, 2), (2, 0, 1), (1, 0, 1)),
}


@dataclass(frozen=True)
class Fish:
    id: int
    zone: int
    meals: int = 0

    def __post_init__(self):
        if not _int(self.id) or self.id not in (1, 2):
            raise ValueError("fish id must be 1 or 2")
        if not _int(self.zone) or not 0 <= self.zone <= 2:
            raise ValueError("fish zone must be 0..2")
        if not _int(self.meals) or not 0 <= self.meals <= TICKS:
            raise ValueError("fish meals must be 0..8")


@dataclass(frozen=True)
class Food:
    id: int
    zone: int
    age: int = 0

    def __post_init__(self):
        if not _int(self.id) or self.id < 0:
            raise ValueError("food id must be a nonnegative integer")
        if not _int(self.zone) or not 0 <= self.zone <= 2:
            raise ValueError("food zone must be 0..2")
        if not _int(self.age) or self.age not in (0, 1):
            raise ValueError("retained food age must be 0 or 1")


@dataclass(frozen=True)
class Event:
    t: int
    kind: str
    zone: int = -1
    source: int = -1
    fish_id: int = 0
    food_id: int = -1
    amount: int = 0
    text: str = ""


@dataclass(frozen=True)
class TickReport:
    t: int
    zone: int
    amount: int
    before_d: Triple
    drift_d: Triple
    final_d: Triple
    before_algae: Triple
    final_algae: Triple
    before_fish: tuple[int, int]
    destinations: tuple[int, int]
    scores: Triple
    meal_order: tuple[int, int]
    meal_gains: tuple[int, int]
    final_meals: tuple[int, int]
    grouped: bool
    events: tuple[Event, ...]


@dataclass(frozen=True)
class State:
    level_id: str
    algae_enabled: bool
    shelters: Triple
    fish: tuple[Fish, Fish]
    d: Triple
    algae: Triple
    t: int = 0
    food: tuple[Food, ...] = ()
    remaining_food: int = FOOD_BUDGET
    next_food_id: int = 0
    events: tuple[Event, ...] = ()
    history: tuple[TickReport, ...] = ()

    def __post_init__(self):
        if self.level_id not in LEVELS or type(self.algae_enabled) is not bool:
            raise ValueError("unknown level or non-boolean algae setting")
        _shelters(self.shelters)
        if not _int(self.t) or not 0 <= self.t <= TICKS:
            raise ValueError("tick must be an integer in 0..8")
        if not isinstance(self.fish, tuple) or len(self.fish) != 2 or any(not isinstance(f, Fish) for f in self.fish) or tuple(f.id for f in self.fish) != (1, 2):
            raise ValueError("fish must be the immutable pair with ids 1, 2")
        if any(f.meals > self.t for f in self.fish):
            raise ValueError("a fish cannot eat more than once per elapsed tick")
        if not _vector(self.d, 3, 0, D_CAP) or not _vector(self.algae, 3, 0, ALGAE_CAP):
            raise ValueError("daphnia/algae must be bounded integer triples")
        if not isinstance(self.food, tuple) or any(not isinstance(f, Food) for f in self.food):
            raise ValueError("food must be an immutable tuple")
        if len({f.id for f in self.food}) != len(self.food):
            raise ValueError("food ids must be unique")
        if not _int(self.remaining_food) or not 0 <= self.remaining_food <= FOOD_BUDGET:
            raise ValueError("remaining food must be 0..10")
        if not _int(self.next_food_id) or not 0 <= self.next_food_id <= FOOD_BUDGET:
            raise ValueError("next food id must be 0..10")
        if self.next_food_id + self.remaining_food != FOOD_BUDGET:
            raise ValueError("food ids and remaining budget must account for ten particles")
        if any(f.id >= self.next_food_id for f in self.food):
            raise ValueError("retained food must already have been issued")
        if not isinstance(self.events, tuple) or any(not isinstance(e, Event) for e in self.events):
            raise ValueError("events must be an immutable tuple")
        if not isinstance(self.history, tuple) or any(not isinstance(r, TickReport) for r in self.history):
            raise ValueError("history must be an immutable tuple")

    @property
    def level(self):
        return LEVELS[self.level_id]

    @property
    def public_id(self):
        return self.level.public_id + ("" if self.algae_enabled else "-no-food-algae")

    @property
    def ended(self):
        return self.t == TICKS

    @property
    def fish_positions(self):
        return tuple(f.zone for f in self.fish)

    @property
    def meals(self):
        return tuple(f.meals for f in self.fish)

    @property
    def inputs(self):
        return tuple((r.zone, r.amount) for r in self.history)

    @property
    def food_used(self):
        return FOOD_BUDGET - self.remaining_food

    @property
    def births(self):
        return sum(e.amount for r in self.history for e in r.events if e.kind == "birth")

    @property
    def algae_events(self):
        return sum(e.amount for r in self.history for e in r.events if e.kind == "algae")

    @property
    def grouped_ticks(self):
        return sum(r.grouped for r in self.history)

    @property
    def a(self):
        return self.algae

    @property
    def log(self):
        return self.history


def initial(level_id="A", algae_enabled=True, shelters=None):
    if level_id not in LEVELS:
        raise ValueError("水槽はA/B/Cから選んでください")
    level = LEVELS[level_id]
    shelters = level.initial_shelters if shelters is None else tuple(shelters)
    return State(level_id, algae_enabled, shelters,
                 tuple(Fish(i + 1, z) for i, z in enumerate(level.initial_fish)),
                 level.initial_d, level.initial_algae)


def edit_shelters(state, shelters):
    """Pre-trial editing only; callers retain the old immutable state for undo."""
    if state.t != 0:
        raise ValueError("隠れ家は開始前だけ動かせます")
    return replace(state, shelters=tuple(shelters))


def drift(d, shelters):
    """Return (new counts, permitted (source,destination) moves, blocked moves).

    Receiving slots are frozen before any departure and reserved in L/M/R
    source order. This intentionally cannot reuse outgoing capacity this tick.
    """
    if not _vector(d, 3, 0, D_CAP):
        raise ValueError("daphnia must be a bounded integer triple")
    _shelters(shelters)
    slots = [D_CAP - value for value in d]
    moves, blocked = [], []
    for source, dest in enumerate(FLOW):
        if d[source] > 2 * shelters[source]:
            if slots[dest] > 0:
                moves.append((source, dest))
                slots[dest] -= 1
            else:
                blocked.append((source, dest))
    result = list(d)
    for source, dest in moves:
        result[source] -= 1
        result[dest] += 1
    return tuple(result), tuple(moves), tuple(blocked)


def fish_destinations(fish, d, shelters, food):
    """Both fish plan against the same post-drift counts and OLD fish positions."""
    scores = tuple(2 * sum(f.zone == z for f in food) + max(0, d[z] - 2 * shelters[z]) for z in range(3))
    destinations = []
    for index, current in enumerate(fish):
        choices = tuple(z for z in range(3) if abs(z - current.zone) <= 1)
        best = max(scores[z] for z in choices)
        priority = (fish[1 - index].zone, current.zone, 0, 1, 2)
        destinations.append(next(z for z in priority if z in choices and scores[z] == best))
    return tuple(destinations), scores


def success(state):
    return state.ended and min(state.meals) >= 3 and sum(state.d) >= 2


def step(state, zone, amount):
    """Commit one feeding action; invalid input raises without changing state.

    Valid steps after the eighth tick return the identical finished state.
    Zone is still validated for a wait, then canonicalized to zero in its log.
    """
    if not _int(zone) or zone not in range(3):
        raise ValueError("給餌区画は左/中/右から選んでください")
    if not _int(amount) or amount not in (0, 1, 2):
        raise ValueError("餌は整数の0/1/2粒です")
    if state.ended:
        return state
    if amount > state.remaining_food:
        raise ValueError("残りの餌が足りません。この刻は進みません")
    if amount == 0:
        zone = 0
    t = state.t + 1
    events = []

    def emit(kind, z=-1, source=-1, fish_id=0, food_id=-1, count=0, text=""):
        events.append(Event(t, kind, z, source, fish_id, food_id, count, text))

    food = list(state.food)
    for food_id in range(state.next_food_id, state.next_food_id + amount):
        food.append(Food(food_id, zone))
        emit("feed", zone, food_id=food_id, count=1, text=f"{ZONE_NAMES[zone]}へ一粒")
    drift_d, moves, blocked = drift(state.d, state.shelters)
    for source, dest in moves:
        emit("drift", dest, source, count=1, text=f"{ZONE_NAMES[source]}→{ZONE_NAMES[dest]}へ漂流")
    for source, dest in blocked:
        emit("drift_blocked", dest, source, text="行先が刻の初めに満員なので残る")
    destinations, scores = fish_destinations(state.fish, drift_d, state.shelters, food)
    fish = [replace(f, zone=destinations[i]) for i, f in enumerate(state.fish)]
    for old, current in zip(state.fish, fish):
        if old.zone != current.zone:
            emit("move", current.zone, old.zone, current.id, text=f"魚{current.id}が{ZONE_NAMES[current.zone]}へ")
    grouped = destinations[0] == destinations[1]
    if grouped:
        emit("school", destinations[0], text="二匹が同じ区画に集まる")
    d = list(drift_d)
    algae = list(state.algae)
    order = (0, 1) if t % 2 else (1, 0)
    gains = [0, 0]
    first_ate_zone = -1
    for index in order:
        current = fish[index]
        z = current.zone
        if first_ate_zone == z:
            emit("competition", z, fish_id=current.id,
                 text=f"魚{fish[order[0]].id}が先に食事。魚{current.id}は残った分を確かめる")
        particles = [f for f in food if f.zone == z]
        if particles:
            oldest = min(particles, key=lambda f: (-f.age, f.id))
            food.remove(oldest)
            gains[index] = 1
            emit("eat_food", z, fish_id=current.id, food_id=oldest.id, count=1,
                 text=f"魚{current.id}が古い餌から一粒食べる")
        elif d[z] > 2 * state.shelters[z]:
            d[z] -= 1
            gains[index] = 1
            emit("eat_d", z, fish_id=current.id, count=1,
                 text=f"魚{current.id}が隠れ家の外のミジンコを一匹食べる")
        else:
            emit("hungry", z, fish_id=current.id,
                 text=f"魚{current.id}は食事なし。守られたミジンコは食べない")
        if gains[index]:
            fish[index] = replace(current, meals=current.meals + 1)
            if index == order[0]:
                first_ate_zone = z
    remaining = []
    for particle in food:
        if particle.age + 1 < 2:
            remaining.append(replace(particle, age=particle.age + 1))
            continue
        z = particle.zone
        if state.algae_enabled and state.shelters[z]:
            if algae[z] < ALGAE_CAP:
                algae[z] += 1
                emit("algae", z, food_id=particle.id, count=1, text="残餌一粒→藻が一つ増える")
            else:
                emit("algae_full", z, food_id=particle.id, text="残餌は消える。藻は上限3")
        else:
            emit("sink", z, food_id=particle.id, text="残餌一粒が底へ沈む")
    for z in range(3):
        if algae[z] > 0 and 0 < d[z] < D_CAP:
            algae[z] -= 1
            d[z] += 1
            emit("birth", z, count=1, text="藻を一つ食べ、ミジンコが一匹増える")
    final_fish = tuple(fish)
    if t == TICKS:
        fulfilled = min(f.meals for f in final_fish) >= 3 and sum(d) >= 2
        emit("finish", text="基本の世話を達成" if fulfilled else "8刻の観察終了。食事と残った数を確認")
    record = TickReport(t, zone, amount, state.d, drift_d, tuple(d), state.algae,
                        tuple(algae), state.fish_positions, destinations, scores,
                        tuple(i + 1 for i in order), tuple(gains),
                        tuple(f.meals for f in final_fish), grouped, tuple(events))
    return State(state.level_id, state.algae_enabled, state.shelters, final_fish,
                 tuple(d), tuple(algae), t, tuple(remaining),
                 state.remaining_food - amount, state.next_food_id + amount,
                 tuple(events), state.history + (record,))


# Teaching interface: independent of the original Controller.
from dataclasses import asdict

class App:
    def __init__(self):
        self.level = 'A'
        self.algae_enabled = True
        self.gated = False
        self.reset()

    def reset(self):
        self.state = initial(self.level, self.algae_enabled) if STAGE >= 3 else initial()
        self.cursor = 0
        self.amount = 0
        self.message = 'READY / SELECTING DOES NOT ADVANCE TIME'

    def press(self, key):
        if key == 'R':
            self.reset()
            return
        if STAGE >= 3 and key in ('1', '2', '3'):
            self.level = 'ABC'[int(key) - 1]
            self.reset()
            return
        if STAGE >= 4 and key == 'B':
            self.algae_enabled = not self.algae_enabled
            self.reset()
            return
        if STAGE == 5 and key == 'X':
            self.state = undo(self.state)
            self.message = 'UNDO / REPLAYED EARLIER INPUTS'
            return
        if STAGE >= 3 and key == 'TAB':
            if self.state.t:
                self.message = 'SHELTERS LOCKED AFTER TICK 0'
            else:
                index = SHELTER_LAYOUTS.index(self.state.shelters)
                self.state = edit_shelters(self.state, SHELTER_LAYOUTS[(index + 1) % len(SHELTER_LAYOUTS)])
                self.message = 'TWO SHELTER CHIPS / TAB CYCLES SIX LAYOUTS'
            return
        if key in ('LEFT', 'RIGHT'):
            self.cursor = (self.cursor + (1 if key == 'RIGHT' else -1)) % 3
        elif STAGE >= 3 and key in ('UP', 'DOWN'):
            self.amount = max(0, min(2, self.amount + (1 if key == 'UP' else -1)))
        elif key in ('SPACE', 'RETURN'):
            if self.state.ended:
                self.message = 'EIGHT TICKS FINISHED / R RESTARTS'
                return
            if STAGE < 3:
                self.state = advance(self.state)
            else:
                amount = self.amount if key == 'RETURN' else 0
                try:
                    self.state = step(self.state, self.cursor, amount)
                except ValueError:
                    self.message = 'NOT ENOUGH FOOD / TICK DID NOT ADVANCE'
                    return
            self.message = 'ONE TICK COMMITTED / RELEASE BEFORE NEXT PRESS'

    def control(self, pressed, held):
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
            return
        if len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True

    def prediction(self):
        if STAGE != 5 or self.state.ended:
            return None
        try:
            return forecast(self.state, self.cursor, self.amount)
        except ValueError:
            return None

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 12, 'PAPER AQUARIUM / STAGE ' + str(STAGE) + ' / ' + ('LAB' if STAGE < 3 else 'LEVEL ' + self.level), 10)
        p.text(12, 27, 'FICTIONAL INTEGER ECOSYSTEM / NOT AQUARIUM CARE ADVICE', 6)
        p.text(12, 45, 'TICK ' + str(s.t) + '/8  FLOW: L>M, M>R, R>M', 7)
        if STAGE >= 3:
            p.text(320, 45, 'FOOD LEFT ' + str(s.remaining_food) + '/10  MEALS ' + str(s.meals), 10)
        for z in range(3):
            x = 12 + z * 196
            p.rect(x, 68, 188, 216, 5)
            p.rect(x + 2, 70, 184, 203, 1)
            p.rectb(x, 68, 188, 216, 10 if z == self.cursor else 6)
            p.text(x + 8, 79, 'LMR'[z] + ' -> ' + 'MRM'[z], 10)
            p.text(x + 66, 79, 'D ' + str(s.d[z]) + ' / CAP 12', 11)
            for chip in range(s.shelters[z]):
                sx = x + 49 + chip * 72
                p.tri(sx - 21, 267, sx, 237, sx + 21, 267, 13)
                p.tri(sx - 10, 267, sx, 254, sx + 10, 267, 1)
            for i in range(s.d[z]):
                dx, dy = x + 21 + (i % 6) * 28, 194 + (i // 6) * 22
                p.circ(dx, dy, 3, 11)
                p.line(dx - 2, dy - 2, dx - 4, dy - 6, 7)
            if STAGE >= 2:
                for f in s.fish:
                    if f.zone != z:
                        continue
                    fx, fy = x + 46 + (f.id - 1) * 70, 142 + (f.id - 1) * 17
                    color = 10 if f.id == 1 else 14
                    p.tri(fx + 10, fy, fx - 8, fy - 8, fx - 8, fy + 8, color)
                    p.tri(fx - 7, fy, fx - 19, fy - 7, fx - 19, fy + 7, color)
                    p.pset(fx + 4, fy - 2, 0)
                    p.text(fx - 4, fy - 19, 'F' + str(f.id), color)
            if STAGE >= 3:
                particles = [f for f in s.food if f.zone == z]
                for i, f in enumerate(particles):
                    p.rect(x + 15 + (i % 8) * 20, 101 + (i // 8) * 9, 4, 4, 9 if f.age else 10)
                p.text(x + 8, 290, 'SHELTER ' + str(s.shelters[z]) + '  ALGAE ' + str(s.algae[z]), 6)
                p.text(x + 8, 302, 'FOOD ' + str(len(particles)) + '  PROTECTED ' + str(2 * s.shelters[z]), 7)
            else:
                p.text(x + 8, 290, 'SHELTER ' + str(s.shelters[z]) + '  PROTECTED ' + str(2 * s.shelters[z]), 6)
        if STAGE < 3:
            p.text(12, 325, 'DRIFT MOVES ' + str(s.moves) + ' / BLOCKED ' + str(s.blocked), 7)
            p.text(12, 341, 'CONSERVES TOTAL D = ' + str(sum(s.d)) + ' / NO MEALS OR BIRTHS', 11)
            if STAGE == 2:
                p.text(12, 357, 'POST-DRIFT SCORES ' + str(s.scores) + ' / BOTH USE OLD FISH POSITIONS' if s.t else 'NO FISH PLAN YET / SPACE TO COMPUTE THE FIRST TICK', 7)
        else:
            p.text(12, 325, 'NEXT INPUT: ' + 'LMR'[self.cursor] + ' / ' + str(self.amount) + ' PARTICLES', 10)
            if s.history:
                last = s.history[-1]
                p.text(12, 341, 'SCORES ' + str(last.scores) + ' / MEAL ORDER ' + str(last.meal_order), 7)
                kinds = ','.join(e.kind for e in s.events if e.kind in ('algae','birth','sink','eat_d','algae_full'))
                p.text(12, 357, 'EVENTS: ' + (kinds or 'feed / drift / meals')[:84], 11)
            if STAGE == 3:
                p.text(12, 377, 'STAGE 03: FOOD DOES NOT AGE / NO ALGAE CONVERSION OR BIRTHS', 6)
            else:
                p.text(12, 377, 'FOOD -> ALGAE: ' + ('ON' if s.algae_enabled else 'OFF') + ' / B RESTARTS COMPARISON', 6)
            if STAGE == 5:
                pred = self.prediction()
                if pred:
                    p.text(12, 396, 'NEXT TICK ONLY: FISH ' + str(pred.fish_positions) + ' MEALS ' + str(pred.meals) + ' D ' + str(pred.d), 10)
                else:
                    p.text(12, 396, 'NO NEXT FORECAST: FINISHED OR INSUFFICIENT FOOD', 8)
            if s.ended:
                p.text(12, 415, 'SUCCESS: BOTH MEALS >=3 AND TOTAL D >=2' if success(s) else 'FINISHED: CHECK MEALS >=3 EACH AND TOTAL D >=2', 11 if success(s) else 8)
        p.text(12, 440, self.message, 10)
        p.text(12, 461, 'ENTER COMMIT / SPACE WAIT / R RESET / ESC QUIT / RELEASE EACH KEY', 7)
        if STAGE >= 3:
            p.text(12, 477, 'LEFT/RIGHT ZONE / UP/DOWN 0-2 FOOD / TAB SHELTERS AT T0 / 1-3 LEVEL', 6)
        if STAGE == 5:
            p.text(12, 493, 'X UNDO BY REPLAY / R DOES NOT RELOAD CODE', 6)

def demo_keys():
    if STAGE < 3:
        return ('SPACE',)
    return ('RIGHT', 'UP', 'UP', 'RETURN', 'RIGHT', 'RETURN', 'LEFT', 'LEFT', 'RETURN', 'SPACE')

def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    import json
    from pathlib import Path
    p.init(612, 516, title='Paper aquarium lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
    app = App()
    sequence = demo_keys() if demo else ()
    keynames = ('LEFT','RIGHT','UP','DOWN','RETURN','SPACE','TAB','X','R','1','2','3','B')
    keys = {k: getattr(p, 'KEY_' + k) for k in keynames}
    limit = max(8, len(sequence) * 2 + 6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            if state_json:
                Path(state_json).write_text(json.dumps({'stage': STAGE, 'state': asdict(app.state), 'scope': 'Real Pyxel; synthetic controls, not physical keyboard or human playtest'}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            p.quit()
        if headless:
            i = p.frame_count // 2
            pressed = {sequence[i]} if demo and p.frame_count % 2 == 0 and i < 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
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone paper aquarium teaching stage')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    # Freeze the caller cwd BEFORE importing or initializing Pyxel.
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value:
            target = Path(value).resolve()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)
STEP 05 / 05

次の一刻を読み、入力を再生して戻す

状態を変えない関数での予測・同じ結果をくり返すリプレイ・状態をまるごと戻す。

forecast は、step をそのまま1回呼びます。今選んでいる区画と粒数で進めた、次の1刻だけを表示し、state には代入しません。この先の餌やりはまだ決まっていないので、8刻での達成や、一番良い答えの予測ではありません。候補を変えると予告だけが変わり、t、餌の残り、履歴は変わりません。餌が足りないときと、8刻が終わったときは、予告を出しません。

X の undo は、今の魚や藻から逆算しません。history の (zone,amount) を入力の列 inputs として取り出し、最後の1件を除いて、同じ水槽・藻の設定・隠れ家から replay(再生)します。同じ最初の値と入力からは同じ結果になるので、魚の位置、食事、餌の年齢と id、藻、ミジンコ、出来事、餌の残り、履歴が、全部そろって戻ります。t0 では同じ state を返します。終わったあとも X で t7 に戻り、別の最後の入力を試せます。

この Undo で戻すのは、確定した1刻です。教材の Tab での、始める前の隠れ家の置き方は「最初の条件」として残し、t0 で X を押しても、直前の置き方の編集には戻りません。元のゲームの Controller は、編集も含めた状態の写しの履歴と、合計256回の判断の上限を、別に持っています。教材の画面には、その編集の Undo、上限、表紙、ヘルプ、V での観察の再生、S での記録、F12 での画像、日本語の文字の描画は移していません。キーの割り当ても元のゲームとは違います。model.py の一番外側の定義と定数すべて、状況 A/B/C のデータは、05 にそのまま入っています。

保存してコードを変えたら、Esc で終了して、python 05_forecast_undo.py をもう一度実行します。R は、読みこみ済みの最初の状態に戻すだけで、Python のファイルを読み直しません。画面は英数字(ASCII)で、説明はこの日本語の本文です。Python のファイル1つを、日本語や空白を含む別のフォルダーに移したり、名前を変えたりしても、Pyxel が入った同じ Python で実行できます。画像や JSON が必要な検査用のコマンド(CLI)は README の指定どおりに使い、すでにある出力は上書きしません。

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

05_forecast_undo.py / 注目する部分
    if not state.history:
        return state
    return replay(state.level_id, state.inputs[:-1], state.algae_enabled, state.shelters)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_forecast_undo.py
macOS / Linux
.venv/bin/python 05_forecast_undo.py
画面で見ること最初の状況 A の予告は、次の刻も MEALS=(0,0)、D=(4,0,0) です。上を1回押すと、予告だけが (1,0) になります。Enter で、予告と同じ本当の状態になり、X で t0・餌10・履歴が空、に戻ります。
  • 1刻の予告と、挑戦全体での達成を分ける
  • 予測しても、本当の状態は変えない
  • 最初の条件と入力の列から、Undo で元に戻す
つまずきやすいところdraw や予告の中で self.state=forecast(...) と代入すると、画面を更新するだけでゲームが進んでしまいます。Undo で t だけを減らしても、食事・餌の id・藻・履歴が残って、つじつまが合わなくなります。
少し変えてみよう

App.reset の self.amount = 0 を self.amount = 1 に変えて、保存 → 終了 → 実行し直してみましょう。最初は t0・餌10のままで、予告だけが MEALS=(1,0) です。Enter で t1・餌9、X で t0・餌10 に戻ります。python exercises/05_pending_forecast.py で、書き換えと入力を実行して確かめられます。最後に0に戻します。

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

"""076 紙の水族館: 教材ステージ 5。架空の整数生態系。"""
STAGE = 5

"""076 紙の水族館: a pure, deterministic, immutable integer toy ecosystem.

Order: legal feeding -> tick -> simultaneous drift -> simultaneous fish plans
-> alternating meals -> food aging/algae -> births -> eight-tick result.
The comparison switch changes ONLY whether expired food becomes algae.
"""
from dataclasses import asdict, dataclass, replace
import hashlib
import json

VERSION = "076-v1"
TICKS = 8
FOOD_BUDGET = 10
D_CAP = 12
ALGAE_CAP = 3
ZONE_NAMES = ("左", "中", "右")
FLOW = (1, 2, 1)
ACTIONS = ((0, 0),) + tuple((z, n) for z in range(3) for n in (1, 2))
SHELTER_LAYOUTS = tuple((a, b, 2 - a - b) for a in range(3) for b in range(3 - a))
Triple = tuple[int, int, int]


def _int(value):
    return isinstance(value, int) and not isinstance(value, bool)


def _vector(value, length, lo, hi):
    return (isinstance(value, tuple) and len(value) == length
            and all(_int(v) and lo <= v <= hi for v in value))


def _shelters(value):
    if not _vector(value, 3, 0, 2) or sum(value) != 2:
        raise ValueError("隠れ家は整数の3区画へ合計2チップ置いてください")


@dataclass(frozen=True)
class Level:
    key: str
    title: str
    initial_fish: tuple[int, int]
    initial_d: Triple
    initial_algae: Triple
    initial_shelters: Triple
    ticks: int = TICKS
    food_budget: int = FOOD_BUDGET
    target_meals: int = 3
    target_d: int = 2

    def __post_init__(self):
        if not self.key or not self.title:
            raise ValueError("level needs a key and title")
        if not _vector(self.initial_fish, 2, 0, 2):
            raise ValueError("two initial fish zones required")
        if not _vector(self.initial_d, 3, 0, D_CAP):
            raise ValueError("invalid initial daphnia")
        if not _vector(self.initial_algae, 3, 0, ALGAE_CAP):
            raise ValueError("invalid initial algae")
        _shelters(self.initial_shelters)
        if (self.ticks, self.food_budget, self.target_meals, self.target_d) != (8, 10, 3, 2):
            raise ValueError("the fixed goal is 8 ticks, 10 food, 3 meals each and 2 daphnia")

    @property
    def id(self):
        return self.key

    @property
    def public_id(self):
        return f"076-{self.key}-v1"


LEVELS = {
    "A": Level("A", "左の岩陰", (0, 0), (4, 0, 0), (0, 0, 0), (2, 0, 0)),
    "B": Level("B", "ふたつの住処", (0, 2), (3, 0, 3), (0, 0, 0), (1, 0, 1)),
    "C": Level("C", "緑のある水槽", (1, 1), (2, 1, 2), (2, 0, 1), (1, 0, 1)),
}


@dataclass(frozen=True)
class Fish:
    id: int
    zone: int
    meals: int = 0

    def __post_init__(self):
        if not _int(self.id) or self.id not in (1, 2):
            raise ValueError("fish id must be 1 or 2")
        if not _int(self.zone) or not 0 <= self.zone <= 2:
            raise ValueError("fish zone must be 0..2")
        if not _int(self.meals) or not 0 <= self.meals <= TICKS:
            raise ValueError("fish meals must be 0..8")


@dataclass(frozen=True)
class Food:
    id: int
    zone: int
    age: int = 0

    def __post_init__(self):
        if not _int(self.id) or self.id < 0:
            raise ValueError("food id must be a nonnegative integer")
        if not _int(self.zone) or not 0 <= self.zone <= 2:
            raise ValueError("food zone must be 0..2")
        if not _int(self.age) or self.age not in (0, 1):
            raise ValueError("retained food age must be 0 or 1")


@dataclass(frozen=True)
class Event:
    t: int
    kind: str
    zone: int = -1
    source: int = -1
    fish_id: int = 0
    food_id: int = -1
    amount: int = 0
    text: str = ""


@dataclass(frozen=True)
class TickReport:
    t: int
    zone: int
    amount: int
    before_d: Triple
    drift_d: Triple
    final_d: Triple
    before_algae: Triple
    final_algae: Triple
    before_fish: tuple[int, int]
    destinations: tuple[int, int]
    scores: Triple
    meal_order: tuple[int, int]
    meal_gains: tuple[int, int]
    final_meals: tuple[int, int]
    grouped: bool
    events: tuple[Event, ...]


@dataclass(frozen=True)
class State:
    level_id: str
    algae_enabled: bool
    shelters: Triple
    fish: tuple[Fish, Fish]
    d: Triple
    algae: Triple
    t: int = 0
    food: tuple[Food, ...] = ()
    remaining_food: int = FOOD_BUDGET
    next_food_id: int = 0
    events: tuple[Event, ...] = ()
    history: tuple[TickReport, ...] = ()

    def __post_init__(self):
        if self.level_id not in LEVELS or type(self.algae_enabled) is not bool:
            raise ValueError("unknown level or non-boolean algae setting")
        _shelters(self.shelters)
        if not _int(self.t) or not 0 <= self.t <= TICKS:
            raise ValueError("tick must be an integer in 0..8")
        if not isinstance(self.fish, tuple) or len(self.fish) != 2 or any(not isinstance(f, Fish) for f in self.fish) or tuple(f.id for f in self.fish) != (1, 2):
            raise ValueError("fish must be the immutable pair with ids 1, 2")
        if any(f.meals > self.t for f in self.fish):
            raise ValueError("a fish cannot eat more than once per elapsed tick")
        if not _vector(self.d, 3, 0, D_CAP) or not _vector(self.algae, 3, 0, ALGAE_CAP):
            raise ValueError("daphnia/algae must be bounded integer triples")
        if not isinstance(self.food, tuple) or any(not isinstance(f, Food) for f in self.food):
            raise ValueError("food must be an immutable tuple")
        if len({f.id for f in self.food}) != len(self.food):
            raise ValueError("food ids must be unique")
        if not _int(self.remaining_food) or not 0 <= self.remaining_food <= FOOD_BUDGET:
            raise ValueError("remaining food must be 0..10")
        if not _int(self.next_food_id) or not 0 <= self.next_food_id <= FOOD_BUDGET:
            raise ValueError("next food id must be 0..10")
        if self.next_food_id + self.remaining_food != FOOD_BUDGET:
            raise ValueError("food ids and remaining budget must account for ten particles")
        if any(f.id >= self.next_food_id for f in self.food):
            raise ValueError("retained food must already have been issued")
        if not isinstance(self.events, tuple) or any(not isinstance(e, Event) for e in self.events):
            raise ValueError("events must be an immutable tuple")
        if not isinstance(self.history, tuple) or any(not isinstance(r, TickReport) for r in self.history):
            raise ValueError("history must be an immutable tuple")

    @property
    def level(self):
        return LEVELS[self.level_id]

    @property
    def public_id(self):
        return self.level.public_id + ("" if self.algae_enabled else "-no-food-algae")

    @property
    def ended(self):
        return self.t == TICKS

    @property
    def fish_positions(self):
        return tuple(f.zone for f in self.fish)

    @property
    def meals(self):
        return tuple(f.meals for f in self.fish)

    @property
    def inputs(self):
        return tuple((r.zone, r.amount) for r in self.history)

    @property
    def food_used(self):
        return FOOD_BUDGET - self.remaining_food

    @property
    def births(self):
        return sum(e.amount for r in self.history for e in r.events if e.kind == "birth")

    @property
    def algae_events(self):
        return sum(e.amount for r in self.history for e in r.events if e.kind == "algae")

    @property
    def grouped_ticks(self):
        return sum(r.grouped for r in self.history)

    @property
    def a(self):
        return self.algae

    @property
    def log(self):
        return self.history


def initial(level_id="A", algae_enabled=True, shelters=None):
    if level_id not in LEVELS:
        raise ValueError("水槽はA/B/Cから選んでください")
    level = LEVELS[level_id]
    shelters = level.initial_shelters if shelters is None else tuple(shelters)
    return State(level_id, algae_enabled, shelters,
                 tuple(Fish(i + 1, z) for i, z in enumerate(level.initial_fish)),
                 level.initial_d, level.initial_algae)


def edit_shelters(state, shelters):
    """Pre-trial editing only; callers retain the old immutable state for undo."""
    if state.t != 0:
        raise ValueError("隠れ家は開始前だけ動かせます")
    return replace(state, shelters=tuple(shelters))


def drift(d, shelters):
    """Return (new counts, permitted (source,destination) moves, blocked moves).

    Receiving slots are frozen before any departure and reserved in L/M/R
    source order. This intentionally cannot reuse outgoing capacity this tick.
    """
    if not _vector(d, 3, 0, D_CAP):
        raise ValueError("daphnia must be a bounded integer triple")
    _shelters(shelters)
    slots = [D_CAP - value for value in d]
    moves, blocked = [], []
    for source, dest in enumerate(FLOW):
        if d[source] > 2 * shelters[source]:
            if slots[dest] > 0:
                moves.append((source, dest))
                slots[dest] -= 1
            else:
                blocked.append((source, dest))
    result = list(d)
    for source, dest in moves:
        result[source] -= 1
        result[dest] += 1
    return tuple(result), tuple(moves), tuple(blocked)


def fish_destinations(fish, d, shelters, food):
    """Both fish plan against the same post-drift counts and OLD fish positions."""
    scores = tuple(2 * sum(f.zone == z for f in food) + max(0, d[z] - 2 * shelters[z]) for z in range(3))
    destinations = []
    for index, current in enumerate(fish):
        choices = tuple(z for z in range(3) if abs(z - current.zone) <= 1)
        best = max(scores[z] for z in choices)
        priority = (fish[1 - index].zone, current.zone, 0, 1, 2)
        destinations.append(next(z for z in priority if z in choices and scores[z] == best))
    return tuple(destinations), scores


def success(state):
    return state.ended and min(state.meals) >= 3 and sum(state.d) >= 2


def step(state, zone, amount):
    """Commit one feeding action; invalid input raises without changing state.

    Valid steps after the eighth tick return the identical finished state.
    Zone is still validated for a wait, then canonicalized to zero in its log.
    """
    if not _int(zone) or zone not in range(3):
        raise ValueError("給餌区画は左/中/右から選んでください")
    if not _int(amount) or amount not in (0, 1, 2):
        raise ValueError("餌は整数の0/1/2粒です")
    if state.ended:
        return state
    if amount > state.remaining_food:
        raise ValueError("残りの餌が足りません。この刻は進みません")
    if amount == 0:
        zone = 0
    t = state.t + 1
    events = []

    def emit(kind, z=-1, source=-1, fish_id=0, food_id=-1, count=0, text=""):
        events.append(Event(t, kind, z, source, fish_id, food_id, count, text))

    food = list(state.food)
    for food_id in range(state.next_food_id, state.next_food_id + amount):
        food.append(Food(food_id, zone))
        emit("feed", zone, food_id=food_id, count=1, text=f"{ZONE_NAMES[zone]}へ一粒")
    drift_d, moves, blocked = drift(state.d, state.shelters)
    for source, dest in moves:
        emit("drift", dest, source, count=1, text=f"{ZONE_NAMES[source]}→{ZONE_NAMES[dest]}へ漂流")
    for source, dest in blocked:
        emit("drift_blocked", dest, source, text="行先が刻の初めに満員なので残る")
    destinations, scores = fish_destinations(state.fish, drift_d, state.shelters, food)
    fish = [replace(f, zone=destinations[i]) for i, f in enumerate(state.fish)]
    for old, current in zip(state.fish, fish):
        if old.zone != current.zone:
            emit("move", current.zone, old.zone, current.id, text=f"魚{current.id}が{ZONE_NAMES[current.zone]}へ")
    grouped = destinations[0] == destinations[1]
    if grouped:
        emit("school", destinations[0], text="二匹が同じ区画に集まる")
    d = list(drift_d)
    algae = list(state.algae)
    order = (0, 1) if t % 2 else (1, 0)
    gains = [0, 0]
    first_ate_zone = -1
    for index in order:
        current = fish[index]
        z = current.zone
        if first_ate_zone == z:
            emit("competition", z, fish_id=current.id,
                 text=f"魚{fish[order[0]].id}が先に食事。魚{current.id}は残った分を確かめる")
        particles = [f for f in food if f.zone == z]
        if particles:
            oldest = min(particles, key=lambda f: (-f.age, f.id))
            food.remove(oldest)
            gains[index] = 1
            emit("eat_food", z, fish_id=current.id, food_id=oldest.id, count=1,
                 text=f"魚{current.id}が古い餌から一粒食べる")
        elif d[z] > 2 * state.shelters[z]:
            d[z] -= 1
            gains[index] = 1
            emit("eat_d", z, fish_id=current.id, count=1,
                 text=f"魚{current.id}が隠れ家の外のミジンコを一匹食べる")
        else:
            emit("hungry", z, fish_id=current.id,
                 text=f"魚{current.id}は食事なし。守られたミジンコは食べない")
        if gains[index]:
            fish[index] = replace(current, meals=current.meals + 1)
            if index == order[0]:
                first_ate_zone = z
    remaining = []
    for particle in food:
        if particle.age + 1 < 2:
            remaining.append(replace(particle, age=particle.age + 1))
            continue
        z = particle.zone
        if state.algae_enabled and state.shelters[z]:
            if algae[z] < ALGAE_CAP:
                algae[z] += 1
                emit("algae", z, food_id=particle.id, count=1, text="残餌一粒→藻が一つ増える")
            else:
                emit("algae_full", z, food_id=particle.id, text="残餌は消える。藻は上限3")
        else:
            emit("sink", z, food_id=particle.id, text="残餌一粒が底へ沈む")
    for z in range(3):
        if algae[z] > 0 and 0 < d[z] < D_CAP:
            algae[z] -= 1
            d[z] += 1
            emit("birth", z, count=1, text="藻を一つ食べ、ミジンコが一匹増える")
    final_fish = tuple(fish)
    if t == TICKS:
        fulfilled = min(f.meals for f in final_fish) >= 3 and sum(d) >= 2
        emit("finish", text="基本の世話を達成" if fulfilled else "8刻の観察終了。食事と残った数を確認")
    record = TickReport(t, zone, amount, state.d, drift_d, tuple(d), state.algae,
                        tuple(algae), state.fish_positions, destinations, scores,
                        tuple(i + 1 for i in order), tuple(gains),
                        tuple(f.meals for f in final_fish), grouped, tuple(events))
    return State(state.level_id, state.algae_enabled, state.shelters, final_fish,
                 tuple(d), tuple(algae), t, tuple(remaining),
                 state.remaining_food - amount, state.next_food_id + amount,
                 tuple(events), state.history + (record,))


def forecast(state, zone=0, amount=0):
    """Exact next tick only, with no side effects or guessed future feeding."""
    return step(state, zone, amount)


def state_dict(state):
    """Canonical JSON-compatible full state, including settings and input log."""
    return json.loads(json.dumps({"version": VERSION, "public_id": state.public_id,
                                  **asdict(state)}, ensure_ascii=False, sort_keys=True))


def state_hash(state):
    encoded = json.dumps(state_dict(state), ensure_ascii=False, sort_keys=True,
                         separators=(",", ":"), allow_nan=False).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()


def replay(level_id, actions, algae_enabled=True, shelters=None):
    state = initial(level_id, algae_enabled, shelters)
    for zone, amount in actions:
        state = step(state, zone, amount)
    return state


def undo(state):
    """Restore the prior trial snapshot by replaying its explicit inputs.

    Editing undo is owned by the UI snapshot stack. No post-trial growth or
    separate animation state is folded into these records.
    """
    if not state.history:
        return state
    return replay(state.level_id, state.inputs[:-1], state.algae_enabled, state.shelters)

# Teaching interface: independent of the original Controller.
from dataclasses import asdict

class App:
    def __init__(self):
        self.level = 'A'
        self.algae_enabled = True
        self.gated = False
        self.reset()

    def reset(self):
        self.state = initial(self.level, self.algae_enabled) if STAGE >= 3 else initial()
        self.cursor = 0
        self.amount = 0
        self.message = 'READY / SELECTING DOES NOT ADVANCE TIME'

    def press(self, key):
        if key == 'R':
            self.reset()
            return
        if STAGE >= 3 and key in ('1', '2', '3'):
            self.level = 'ABC'[int(key) - 1]
            self.reset()
            return
        if STAGE >= 4 and key == 'B':
            self.algae_enabled = not self.algae_enabled
            self.reset()
            return
        if STAGE == 5 and key == 'X':
            self.state = undo(self.state)
            self.message = 'UNDO / REPLAYED EARLIER INPUTS'
            return
        if STAGE >= 3 and key == 'TAB':
            if self.state.t:
                self.message = 'SHELTERS LOCKED AFTER TICK 0'
            else:
                index = SHELTER_LAYOUTS.index(self.state.shelters)
                self.state = edit_shelters(self.state, SHELTER_LAYOUTS[(index + 1) % len(SHELTER_LAYOUTS)])
                self.message = 'TWO SHELTER CHIPS / TAB CYCLES SIX LAYOUTS'
            return
        if key in ('LEFT', 'RIGHT'):
            self.cursor = (self.cursor + (1 if key == 'RIGHT' else -1)) % 3
        elif STAGE >= 3 and key in ('UP', 'DOWN'):
            self.amount = max(0, min(2, self.amount + (1 if key == 'UP' else -1)))
        elif key in ('SPACE', 'RETURN'):
            if self.state.ended:
                self.message = 'EIGHT TICKS FINISHED / R RESTARTS'
                return
            if STAGE < 3:
                self.state = advance(self.state)
            else:
                amount = self.amount if key == 'RETURN' else 0
                try:
                    self.state = step(self.state, self.cursor, amount)
                except ValueError:
                    self.message = 'NOT ENOUGH FOOD / TICK DID NOT ADVANCE'
                    return
            self.message = 'ONE TICK COMMITTED / RELEASE BEFORE NEXT PRESS'

    def control(self, pressed, held):
        if self.gated:
            if not pressed and not held:
                self.gated = False
            return
        if len(pressed | held) > 1:
            self.gated = True
            self.message = 'CHORD REJECTED / RELEASE ALL KEYS'
            return
        if len(pressed) == 1:
            self.press(next(iter(pressed)))
            self.gated = True

    def prediction(self):
        if STAGE != 5 or self.state.ended:
            return None
        try:
            return forecast(self.state, self.cursor, self.amount)
        except ValueError:
            return None

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(12, 12, 'PAPER AQUARIUM / STAGE ' + str(STAGE) + ' / ' + ('LAB' if STAGE < 3 else 'LEVEL ' + self.level), 10)
        p.text(12, 27, 'FICTIONAL INTEGER ECOSYSTEM / NOT AQUARIUM CARE ADVICE', 6)
        p.text(12, 45, 'TICK ' + str(s.t) + '/8  FLOW: L>M, M>R, R>M', 7)
        if STAGE >= 3:
            p.text(320, 45, 'FOOD LEFT ' + str(s.remaining_food) + '/10  MEALS ' + str(s.meals), 10)
        for z in range(3):
            x = 12 + z * 196
            p.rect(x, 68, 188, 216, 5)
            p.rect(x + 2, 70, 184, 203, 1)
            p.rectb(x, 68, 188, 216, 10 if z == self.cursor else 6)
            p.text(x + 8, 79, 'LMR'[z] + ' -> ' + 'MRM'[z], 10)
            p.text(x + 66, 79, 'D ' + str(s.d[z]) + ' / CAP 12', 11)
            for chip in range(s.shelters[z]):
                sx = x + 49 + chip * 72
                p.tri(sx - 21, 267, sx, 237, sx + 21, 267, 13)
                p.tri(sx - 10, 267, sx, 254, sx + 10, 267, 1)
            for i in range(s.d[z]):
                dx, dy = x + 21 + (i % 6) * 28, 194 + (i // 6) * 22
                p.circ(dx, dy, 3, 11)
                p.line(dx - 2, dy - 2, dx - 4, dy - 6, 7)
            if STAGE >= 2:
                for f in s.fish:
                    if f.zone != z:
                        continue
                    fx, fy = x + 46 + (f.id - 1) * 70, 142 + (f.id - 1) * 17
                    color = 10 if f.id == 1 else 14
                    p.tri(fx + 10, fy, fx - 8, fy - 8, fx - 8, fy + 8, color)
                    p.tri(fx - 7, fy, fx - 19, fy - 7, fx - 19, fy + 7, color)
                    p.pset(fx + 4, fy - 2, 0)
                    p.text(fx - 4, fy - 19, 'F' + str(f.id), color)
            if STAGE >= 3:
                particles = [f for f in s.food if f.zone == z]
                for i, f in enumerate(particles):
                    p.rect(x + 15 + (i % 8) * 20, 101 + (i // 8) * 9, 4, 4, 9 if f.age else 10)
                p.text(x + 8, 290, 'SHELTER ' + str(s.shelters[z]) + '  ALGAE ' + str(s.algae[z]), 6)
                p.text(x + 8, 302, 'FOOD ' + str(len(particles)) + '  PROTECTED ' + str(2 * s.shelters[z]), 7)
            else:
                p.text(x + 8, 290, 'SHELTER ' + str(s.shelters[z]) + '  PROTECTED ' + str(2 * s.shelters[z]), 6)
        if STAGE < 3:
            p.text(12, 325, 'DRIFT MOVES ' + str(s.moves) + ' / BLOCKED ' + str(s.blocked), 7)
            p.text(12, 341, 'CONSERVES TOTAL D = ' + str(sum(s.d)) + ' / NO MEALS OR BIRTHS', 11)
            if STAGE == 2:
                p.text(12, 357, 'POST-DRIFT SCORES ' + str(s.scores) + ' / BOTH USE OLD FISH POSITIONS' if s.t else 'NO FISH PLAN YET / SPACE TO COMPUTE THE FIRST TICK', 7)
        else:
            p.text(12, 325, 'NEXT INPUT: ' + 'LMR'[self.cursor] + ' / ' + str(self.amount) + ' PARTICLES', 10)
            if s.history:
                last = s.history[-1]
                p.text(12, 341, 'SCORES ' + str(last.scores) + ' / MEAL ORDER ' + str(last.meal_order), 7)
                kinds = ','.join(e.kind for e in s.events if e.kind in ('algae','birth','sink','eat_d','algae_full'))
                p.text(12, 357, 'EVENTS: ' + (kinds or 'feed / drift / meals')[:84], 11)
            if STAGE == 3:
                p.text(12, 377, 'STAGE 03: FOOD DOES NOT AGE / NO ALGAE CONVERSION OR BIRTHS', 6)
            else:
                p.text(12, 377, 'FOOD -> ALGAE: ' + ('ON' if s.algae_enabled else 'OFF') + ' / B RESTARTS COMPARISON', 6)
            if STAGE == 5:
                pred = self.prediction()
                if pred:
                    p.text(12, 396, 'NEXT TICK ONLY: FISH ' + str(pred.fish_positions) + ' MEALS ' + str(pred.meals) + ' D ' + str(pred.d), 10)
                else:
                    p.text(12, 396, 'NO NEXT FORECAST: FINISHED OR INSUFFICIENT FOOD', 8)
            if s.ended:
                p.text(12, 415, 'SUCCESS: BOTH MEALS >=3 AND TOTAL D >=2' if success(s) else 'FINISHED: CHECK MEALS >=3 EACH AND TOTAL D >=2', 11 if success(s) else 8)
        p.text(12, 440, self.message, 10)
        p.text(12, 461, 'ENTER COMMIT / SPACE WAIT / R RESET / ESC QUIT / RELEASE EACH KEY', 7)
        if STAGE >= 3:
            p.text(12, 477, 'LEFT/RIGHT ZONE / UP/DOWN 0-2 FOOD / TAB SHELTERS AT T0 / 1-3 LEVEL', 6)
        if STAGE == 5:
            p.text(12, 493, 'X UNDO BY REPLAY / R DOES NOT RELOAD CODE', 6)

def demo_keys():
    if STAGE < 3:
        return ('SPACE',)
    return ('RIGHT', 'UP', 'UP', 'RETURN', 'RIGHT', 'RETURN', 'LEFT', 'LEFT', 'RETURN', 'SPACE')

def main(headless=False, screenshot=None, state_json=None, demo=False):
    import pyxel as p
    import json
    from pathlib import Path
    p.init(612, 516, title='Paper aquarium lesson ' + str(STAGE), fps=30, display_scale=1, headless=headless)
    app = App()
    sequence = demo_keys() if demo else ()
    keynames = ('LEFT','RIGHT','UP','DOWN','RETURN','SPACE','TAB','X','R','1','2','3','B')
    keys = {k: getattr(p, 'KEY_' + k) for k in keynames}
    limit = max(8, len(sequence) * 2 + 6)
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count >= limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot, 1)
            if state_json:
                Path(state_json).write_text(json.dumps({'stage': STAGE, 'state': asdict(app.state), 'scope': 'Real Pyxel; synthetic controls, not physical keyboard or human playtest'}, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
            p.quit()
        if headless:
            i = p.frame_count // 2
            pressed = {sequence[i]} if demo and p.frame_count % 2 == 0 and i < 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
    from pathlib import Path
    parser = argparse.ArgumentParser(description='Standalone paper aquarium teaching stage')
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--demo-checkpoint', action='store_true')
    parser.add_argument('--screenshot')
    parser.add_argument('--state-json')
    args = parser.parse_args()
    if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke:
        parser.error('demo and outputs require --headless-smoke')
    # Freeze the caller cwd BEFORE importing or initializing Pyxel.
    for field, suffix in (('screenshot', '.png'), ('state_json', '.json')):
        value = getattr(args, field)
        if value:
            target = Path(value).resolve()
            if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists():
                parser.error('output must be a new ' + suffix + ' in an existing directory')
            setattr(args, field, str(target))
    main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint)
AFTER STEP 05

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

ルールのまとめ:餌やり → 受け入れ枠を固定して流される → 前の位置から魚2匹が同時に計画 → 交代で食事 → 残った餌の年齢と藻になる → 誕生。8刻・10粒で、魚それぞれ3回以上の食事と、ミジンコ2匹以上。

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

比べる点5ステップの教材元の完成版
段階01 流されるだけの専用の最初の値、02 元のゲームの状況 B で魚の移動だけ、03 年齢の更新なし、04 元のゲームの step、05 元のゲームのルール全部。最初から、すべての機能があります。
入力と表示英数字(ASCII)で612×516。表紙なし、Tab で6通りの置き方、X は1刻ぶんの入力を再生して戻す。音なし。日本語の文字、表紙、置き方のチップの選択、編集の状態の写しの Undo、合計256回の判断、V での再生、S/F12。
単独実行それぞれの Python ファイルに、必要なルール全部と MIT ライセンスを入れています。assets は不要。Pyxel 2.9.9 は別に必要です。model.py/controller.py/assets を読みこみます。
予告05 は、候補で進めた次の1刻だけ。同じ model.forecast での、正確な1刻。画面には表示の切り替えもあります。

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

  1. model.py の drift:slots と予約の順番
  2. model.py の fish_destinations:前の位置と、同点のときの優先順位
  3. model.py の step:年齢・上限・誕生の順番
  4. model.py の state_dict/state_hash/replay/undo
  5. controller.py の commit:編集の履歴と、256回の判断の上限
  6. controller.py の start_replay/replay_tick と view_state
  7. app.py の event_lines:藻になる → 誕生、の表示
自分のゲームに変えるなら

最初は、数字かルールを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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。

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