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