シミュレーションLESSON 65三日間の架空店舗と所有品の予約計画原作099

あしたも開く店の作り方

明日わたす1個を、今日の棚から取っておく。
棚の品物(在庫)を、名前を付けた予約に移します。翌朝の受け取りと、紹介で来るお客を、決まった順番で処理する小さな店を作ります。

完成版の遊び方3日間、店を開きます。今日の品物を1人のお客のために取っておき(予約)、翌日の受け取りや、友だちの紹介につなげます。

あしたも開く店の元の完成版。自動操作によるPyxelの実描画画面。
元の完成版の画面です。この講座では、仕組みがわかりやすいように小さくした「教材版」を、5つのステップで作ります。
PLAY / 試験公開

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

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

完成版の主な操作:左右キーで品物や行動を選ぶ、上下キーで個数を変える、Enterで予告を見て確定する、Tabで予定と来店帳を見る、Xで戻す、Sで保存、Lで読み込み

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

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

うまく表示されないとき

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

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

予約を「品物の持ち主が移ること」として扱う。

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

1. 書き換えない状態、売り買いの帳簿、描画と更新を分ける
2. 自由在庫(まだだれのものでもない在庫)と、名前付きの予約の持ち主の関係
3. 1日の場面の切り替え(フェーズ)、棚の容量、3日間の区切り
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上・Pyxel 2.9.9 / 変数・if・for・関数・タプルとリストの基本 / dataclass / replace・書き換えない状態・意味入力(キーを「売る」などの意味に直した入力)は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
Z:販売・予約・受け取り(受け取りは必ず行う)、03以降は仕入れの確定と閉店も / X:ふつうの購入や予約を見送る(受け取りは断れない) / 左右:03以降、朝に商品を選ぶ / 上下:03以降、朝の仕入れの案を0〜3個に変える / U:05だけ、確定した操作を1つ戻す(Undo) / R:履歴も含めて最初から / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_sales.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

売るたびに在庫とコインを一緒に動かす

書き換えない状態、売り買いの帳簿、描画と更新を分ける。

最初は、ハナがマグを、レンが栞(しおり)を買いに来るだけの、2人の練習です。Z で売る、X で見送る、R で最初からやり直します。最初の手持ちは架空のお金で6コイン、自由在庫(まだだれのものでもない在庫)はマグ1個と栞1個です。この段階だけは、レンの希望をふつうの購入に変えています。仕入れ・予約・家賃・翌日は、まだありません。これは、元のゲーム全体を小さくしたものではありません。「1回売ると状態がどう変わるか(遷移)」だけを取り出した練習です。

State は、その時点の帳簿をまとめた箱です。frozen=True にすると、s.cash += 2 のように元の状態を書き換える操作ができなくなります。step は元の状態を読むだけで、replace で新しい状態を返します。売るときは、free[item] を1減らす、cash に2を足す、sold を1増やす、の3つを1回の判断でまとめて更新します。品物を減らし忘れると、いくらでも売れてしまいます。sold だけを増やすと、画面のコインと帳簿が合わなくなります。

free はタプル(あとから変えられない並び)なので、変えるときは list(s.free) で一時的なコピーを作り、最後に tuple に戻します。番号0が MUG(丸いマグ)、1が STAR(星の栞)です。星そのものを売るわけではありません。customer(s) は今のお客を返します。actions(s) は、今できる意味入力(「売る」「見送る」のように、キーを意味に直した入力)を並べて返します。在庫が0なら、serve は並べません。step は、並んでいない操作を ValueError(エラー)で断り、状態を返しません。そのため、呼び出した側は元の状態をそのまま使えます。

App.control はキーを意味入力に変えるだけで、draw は帳簿を読むだけです。長押しや、Z から X への押しかえで、2人に続けて売ってしまわないようにします。そのため、一度入力したら、操作キーを全部離すまで待ちます。2つ同時に押したときも無効です。毎フレーム(1コマごと)に売る作り方とはちがうので、30fps(1秒に30回の描画)という数字が、売れる速さを決めることはありません。

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

01_sales.py / 注目する部分
        free[item] -= 1
        cash += SELL_PRICE
        sold += 1

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_sales.py
macOS / Linux
.venv/bin/python 01_sales.py
画面で見ること最初の画面は DAY 1 CUSTOMER・COINS 6・SOLD 0・OWNED 2 です。Z を1回押して離すと、COINS 8・SOLD 1・MUG x0・STAR x1 になり、矢印が REN に移ります。2回目の Z で COINS 10・SOLD 2・OWNED 0 になり、SALES PRACTICE COMPLETE(販売の練習完了)と表示されます。
  • 売るとき、帳簿の3つの値をいっしょに更新する
  • 見送ると、次のお客に進むだけ
  • draw やキーの長押しでは売れない
つまずきやすいところdraw の中で cash や index を増やすと、画面を描くたびに商売が進んでしまいます。状態を変える処理は step だけに集めます。見送ったときも index(今のお客の番号)は進みますが、在庫・cash・sold は変わりません。
少し変えてみよう

SELL_PRICE を2から3に変えて起動し、Z を2回押してみましょう。COINS 12・SOLD 2・OWNED 0 になります。6 + 3 × 2 = 12 と計算して、合っているか確かめましょう。確かめたら2に戻します。

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

"""あしたも開く店: 第1段階。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace

ITEMS = ("MUG", "STAR")
NAMES = {"ハナ": "HANA", "レン": "REN", "ミナ": "MINA"}
SELL_PRICE = 2


def label(name):
    if name.endswith("の友人"):
        return NAMES.get(name[:-3], "GUEST") + " FRIEND"
    return NAMES.get(name, "GUEST")

QUEUE = (("ハナ", 0, "buy", ""), ("レン", 1, "buy", ""))

@dataclass(frozen=True)
class State:
    phase: str = "customer"
    cash: int = 6
    free: tuple = (1, 1)
    sold: int = 0
    index: int = 0


def initial():
    return State()


def shelf(s):
    return sum(s.free)


def customer(s):
    return QUEUE[s.index] if s.phase == "customer" else None


def actions(s):
    if s.phase == "customer":
        yield ("skip",)
        if s.free[customer(s)[1]] > 0:
            yield ("serve",)


def step(s, action):
    if type(action) not in (tuple, list) or not action or type(action[0]) is not str:
        raise ValueError("INVALID ACTION")
    if any(type(n) is not int for n in action[1:]):
        raise ValueError("INVALID ACTION COUNTS")
    action = tuple(action)
    if action not in actions(s):
        raise ValueError("ACTION NOT AVAILABLE")
    name, item, kind, origin = customer(s)
    free = list(s.free)
    cash, sold = s.cash, s.sold
    if action[0] == "skip":
        pass
    else:
        free[item] -= 1
        cash += SELL_PRICE
        sold += 1
    index = s.index + 1
    return replace(s, free=tuple(free), cash=cash, sold=sold, index=index,
                   phase="end" if index == len(QUEUE) else "customer")

class App:
    """The pure model is also usable without importing Pyxel."""
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.stock = [0, 0]
        self.item = 0
        self.previous = set()
        self.gated = False
        self.message = "READY"
        self.history = []

    def apply(self, action):
        try:
            after = step(self.state, action)
        except ValueError as error:
            self.message = str(error)
            return False
        # Undo is introduced in stage 5.
        self.state = after
        self.stock = [0, 0]
        self.message = action[0].upper()
        return True

    def control(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gated:
            if not down:
                self.gated = False
            return
        if not fresh:
            return
        self.gated = True
        if len(down) != 1:
            self.message = "RELEASE ALL KEYS"
            return
        key = next(iter(fresh))
        if key == "R":
            self.reset()
            self.gated = True
            self.previous = down
            return
        
        s = self.state
        if s.phase == "end":
            self.message = "ENDED / R TO RESET"
            return
        
        if s.phase == "customer":
            if key == "Z":
                self.apply(("collect",) if customer(s)[2] == "pickup" else ("serve",))
            elif key == "X":
                self.apply(("skip",))
        

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(10, 10, "TOMORROW'S SHOP / STAGE 1", 10)
        day = getattr(s, "day", 1)
        p.text(10, 26, "DAY %d  %s" % (day, s.phase.upper()), 7)
        p.text(10, 40, "COINS %d   SOLD %d   OWNED %d" % (s.cash, s.sold, shelf(s)), 11)
        p.text(10, 55, "FICTIONAL COINS / NO REAL PURCHASES", 5)
        p.rectb(8, 70, 210, 105, 5)
        p.rectb(228, 70, 212, 105, 5)
        p.text(16, 78, "FREE STOCK", 6)
        for item in (0, 1):
            y = 98 + item * 28
            p.text(16, y, ITEMS[item], 7)
            for n in range(s.free[item]):
                x = 60 + n * 20
                if item == 0:
                    p.rect(x, y - 3, 12, 12, 12)
                    p.rectb(x + 10, y, 5, 6, 12)
                else:
                    p.rect(x, y - 3, 10, 15, 10)
                    p.text(x + 3, y + 1, "*", 1)
            p.text(174, y, "x%d" % s.free[item], 7)
        p.text(16, 156, "OWNED = FREE + RESERVED", 6)
        p.text(236, 78, "NAMED RESERVATIONS", 6)
        reservations = getattr(s, "reservations", ())
        for row, (name, item, due) in enumerate(reservations):
            p.text(236, 95 + row * 13, "%s %s D%d" % (label(name), ITEMS[item], due), 10)
        if not reservations:
            p.text(236, 98, "NONE", 5)
        p.text(10, 187, "CUSTOMER QUEUE / ARROW = CURRENT", 7)
        queue = s.queue if hasattr(s, "queue") else QUEUE
        for row, (name, item, kind, origin) in enumerate(queue):
            prefix = "> " if row == s.index and s.phase == "customer" else "  "
            line = prefix + label(name) + " / " + ITEMS[item] + " / " + kind.upper()
            p.text(10, 202 + row * 13, line, 10 if prefix == "> " else 6)
        if s.phase == "morning":
            p.text(10, 202, "BUY MUG %d  STAR %d / SELECT %s" % (*self.stock, ITEMS[self.item]), 10)
            p.text(10, 219, "RESERVATIONS STILL OCCUPY SHELF SPACE", 6)
            p.text(10, 235, "LEFT/RIGHT ITEM  UP/DOWN COUNT  Z OPEN", 7)
        if s.phase == "close":
            p.text(10, 260, "Z CLOSE / PAY 2 COINS", 10)
        elif s.phase == "end":
            end_text = "SALES PRACTICE COMPLETE"
            p.text(10, 260, end_text, 10)
        
        p.text(10, 287, self.message[:70], 11)
        p.text(10, 304, "Z SELL  X SKIP  R RESET  ESC QUIT", 6)

KEYS = ("LEFT", "RIGHT", "UP", "DOWN", "Z", "X", "U", "R")
DEMO_KEYS = ('Z',)


def parse_args(argv=None):
    """Validate all bounded QA flags before any Pyxel import or initialization."""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error("--frames must be between %d and 600" % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir():
            parser.error("screenshot must be a NEW .png file in an existing directory")
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    from dataclasses import asdict
    import pyxel as p
    app = App()
    p.init(448, 320, title="Tomorrow's Shop / lesson 1", fps=30,
           display_scale=2, headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                p.screenshot(args.screenshot, scale=2)
            print(json.dumps({"state": asdict(app.state), "history_length": len(app.history)}, ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            index = p.frame_count // 2
            down = {DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keys.items() if p.btn(value)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 02 / 05

持っている一品に予約者の名札を付ける

自由在庫と、名前付きの予約の持ち主の関係。

レンの希望を reserve(予約)に変えます。ハナにふつうに売ったあと、レンのところで Z を押すと、栞が自由在庫から消えます。そして、右の NAMED RESERVATIONS(名前付きの予約)に REN STAR D2 と表示されます。予約を受けた瞬間には、お金も売れた個数も増えません。お客が取りに来たときに売る処理は、次の段階で作ります。この段階は、予約ができた時点で END(終わり)になります。

reservations の1件は、(名前, 商品番号, 受け取る日)のタプルです。名前だけでは商品が決まらず、商品だけではだれのものか分かりません。そこで、3つをいっしょに保存します。ここでは1日目だけなので、受け取る日は1 + 1です。shelf(s) は sum(s.free) + len(s.reservations) です。自由在庫から1個減らして予約を1件足すので、店が持っている品物の数は変わりません。

この作品でいちばん大事なのは、予約を「あとで品物が自然にわいてくる注文」として扱わないことです。今、自由在庫がなければ、serve そのものが候補に出ません。予約は、まだだれのものでもない1個を、レンのために取っておく操作です。品物は棚の上に残るので、翌朝に仕入れられる数(容量)を考えるときにも数えます。

この段階の右の欄は、ゲームの世界に保存された予約を表示しています。R は、練習全体を最初に戻すキーです。1つの予約を取り消す、ゲームの中の行動ではありません。また、この予約は、現実のお客や商売を扱うものでもありません。ハナ・レン・ミナは、この教材の中だけの架空の人物です。

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

02_reservations.py / 注目する部分
    elif kind == "reserve":
        free[item] -= 1
        reservations.append((name, item, 1 + 1))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_reservations.py
macOS / Linux
.venv/bin/python 02_reservations.py
画面で見ることZ を2回押すと、COINS 8・SOLD 1・OWNED 1 になります。自由在庫はマグも栞も0で、右の欄には REN STAR D2 が1件あります。RESERVED FOR DAY 2 / NEXT: STAGE 3 と表示されます。予約しただけでは、COINS 10 にはなりません。
  • 予約した時点では、売上は0
  • 品物が、自由在庫から名前付きの予約に移る
  • 店が持っている数が同じでも、自由に使える品物はちがう
つまずきやすいところ予約を足すだけで自由在庫を減らさないと、同じ栞を2人に約束できてしまいます。反対に、両方を減らすと品物が消えてしまいます。自由在庫から予約へ「移す」と考えましょう。
少し変えてみよう

R のあと、ハナには Z、レンには X を押してみましょう。COINS 8・SOLD 1・OWNED 1 は同じですが、STAR x1 が自由在庫に残り、予約の欄は NONE(なし)です。金額だけでなく、在庫の持ち主がだれかも比べてみましょう。

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

"""あしたも開く店: 第2段階。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace

ITEMS = ("MUG", "STAR")
NAMES = {"ハナ": "HANA", "レン": "REN", "ミナ": "MINA"}
SELL_PRICE = 2


def label(name):
    if name.endswith("の友人"):
        return NAMES.get(name[:-3], "GUEST") + " FRIEND"
    return NAMES.get(name, "GUEST")

QUEUE = (("ハナ", 0, "buy", ""), ("レン", 1, "reserve", ""))

@dataclass(frozen=True)
class State:
    phase: str = "customer"
    cash: int = 6
    free: tuple = (1, 1)
    sold: int = 0
    index: int = 0
    reservations: tuple = ()


def initial():
    return State()


def shelf(s):
    return sum(s.free) + len(s.reservations)


def customer(s):
    return QUEUE[s.index] if s.phase == "customer" else None


def actions(s):
    if s.phase == "customer":
        yield ("skip",)
        if s.free[customer(s)[1]] > 0:
            yield ("serve",)


def step(s, action):
    if type(action) not in (tuple, list) or not action or type(action[0]) is not str:
        raise ValueError("INVALID ACTION")
    if any(type(n) is not int for n in action[1:]):
        raise ValueError("INVALID ACTION COUNTS")
    action = tuple(action)
    if action not in actions(s):
        raise ValueError("ACTION NOT AVAILABLE")
    name, item, kind, origin = customer(s)
    free = list(s.free)
    cash, sold = s.cash, s.sold
    reservations = list(s.reservations)
    if action[0] == "skip":
        pass
    elif kind == "reserve":
        free[item] -= 1
        reservations.append((name, item, 1 + 1))
    else:
        free[item] -= 1
        cash += SELL_PRICE
        sold += 1
    index = s.index + 1
    return replace(s, free=tuple(free), cash=cash, sold=sold, index=index,
                   phase="end" if index == len(QUEUE) else "customer",
                   reservations=tuple(reservations))

class App:
    """The pure model is also usable without importing Pyxel."""
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.stock = [0, 0]
        self.item = 0
        self.previous = set()
        self.gated = False
        self.message = "READY"
        self.history = []

    def apply(self, action):
        try:
            after = step(self.state, action)
        except ValueError as error:
            self.message = str(error)
            return False
        # Undo is introduced in stage 5.
        self.state = after
        self.stock = [0, 0]
        self.message = action[0].upper()
        return True

    def control(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gated:
            if not down:
                self.gated = False
            return
        if not fresh:
            return
        self.gated = True
        if len(down) != 1:
            self.message = "RELEASE ALL KEYS"
            return
        key = next(iter(fresh))
        if key == "R":
            self.reset()
            self.gated = True
            self.previous = down
            return
        
        s = self.state
        if s.phase == "end":
            self.message = "ENDED / R TO RESET"
            return
        
        if s.phase == "customer":
            if key == "Z":
                self.apply(("collect",) if customer(s)[2] == "pickup" else ("serve",))
            elif key == "X":
                self.apply(("skip",))
        

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(10, 10, "TOMORROW'S SHOP / STAGE 2", 10)
        day = getattr(s, "day", 1)
        p.text(10, 26, "DAY %d  %s" % (day, s.phase.upper()), 7)
        p.text(10, 40, "COINS %d   SOLD %d   OWNED %d" % (s.cash, s.sold, shelf(s)), 11)
        p.text(10, 55, "FICTIONAL COINS / NO REAL PURCHASES", 5)
        p.rectb(8, 70, 210, 105, 5)
        p.rectb(228, 70, 212, 105, 5)
        p.text(16, 78, "FREE STOCK", 6)
        for item in (0, 1):
            y = 98 + item * 28
            p.text(16, y, ITEMS[item], 7)
            for n in range(s.free[item]):
                x = 60 + n * 20
                if item == 0:
                    p.rect(x, y - 3, 12, 12, 12)
                    p.rectb(x + 10, y, 5, 6, 12)
                else:
                    p.rect(x, y - 3, 10, 15, 10)
                    p.text(x + 3, y + 1, "*", 1)
            p.text(174, y, "x%d" % s.free[item], 7)
        p.text(16, 156, "OWNED = FREE + RESERVED", 6)
        p.text(236, 78, "NAMED RESERVATIONS", 6)
        reservations = getattr(s, "reservations", ())
        for row, (name, item, due) in enumerate(reservations):
            p.text(236, 95 + row * 13, "%s %s D%d" % (label(name), ITEMS[item], due), 10)
        if not reservations:
            p.text(236, 98, "NONE", 5)
        p.text(10, 187, "CUSTOMER QUEUE / ARROW = CURRENT", 7)
        queue = s.queue if hasattr(s, "queue") else QUEUE
        for row, (name, item, kind, origin) in enumerate(queue):
            prefix = "> " if row == s.index and s.phase == "customer" else "  "
            line = prefix + label(name) + " / " + ITEMS[item] + " / " + kind.upper()
            p.text(10, 202 + row * 13, line, 10 if prefix == "> " else 6)
        if s.phase == "morning":
            p.text(10, 202, "BUY MUG %d  STAR %d / SELECT %s" % (*self.stock, ITEMS[self.item]), 10)
            p.text(10, 219, "RESERVATIONS STILL OCCUPY SHELF SPACE", 6)
            p.text(10, 235, "LEFT/RIGHT ITEM  UP/DOWN COUNT  Z OPEN", 7)
        if s.phase == "close":
            p.text(10, 260, "Z CLOSE / PAY 2 COINS", 10)
        elif s.phase == "end":
            end_text = "RESERVED FOR DAY 2 / NEXT: STAGE 3"
            p.text(10, 260, end_text, 10)
        
        p.text(10, 287, self.message[:70], 11)
        p.text(10, 304, "Z SELL/RESERVE  X SKIP  R RESET  ESC QUIT", 6)

KEYS = ("LEFT", "RIGHT", "UP", "DOWN", "Z", "X", "U", "R")
DEMO_KEYS = ('Z', 'Z')


def parse_args(argv=None):
    """Validate all bounded QA flags before any Pyxel import or initialization."""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error("--frames must be between %d and 600" % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir():
            parser.error("screenshot must be a NEW .png file in an existing directory")
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    from dataclasses import asdict
    import pyxel as p
    app = App()
    p.init(448, 320, title="Tomorrow's Shop / lesson 2", fps=30,
           display_scale=2, headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                p.screenshot(args.screenshot, scale=2)
            print(json.dumps({"state": asdict(app.state), "history_length": len(app.history)}, ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            index = p.frame_count // 2
            down = {DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keys.items() if p.btn(value)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 03 / 05

朝の仕入、翌日受取、閉店を順番に進める

1日の場面の切り替え(フェーズ)、棚の容量、3日間の区切り。

ここから、元のゲームの場面Aの3日間を扱います。最初は morning(朝)です。左右キーで商品を選び、上下キーで仕入れの案を0〜3個の間で決め、Z で店を開けます。案を変えるだけでは、お金も在庫も動きません。仕入れは1個1コインで、1回の朝に合計3個までです。棚は、予約を含めて5つ分です。お金が足りないときも断ります。forecast は帳簿を一度も変えずに、仕入れのチェックと、次のお客の列を計算します。お金を払うのは、実行するときの step だけです。

大切なのは、朝の仕入れが受け取りより先、という順番です。翌日に取りに来る予約の品物も、仕入れる瞬間にはまだ棚にあります。棚が5つ分なら、このあと受け取りのお客が持って行って減る予定でも、先に6個目を仕入れることはできません。forecast は、今の shelf(s) に仕入れる数を足して調べます。

朝のお客の列は、まずその日に受け取りに来るお客を、予約した順に並べます。そのうしろに、TABLE にある固定客(毎日決まって来るお客)2人をつなげます。この段階では、紹介は省きます。受け取りのお客に使える意味入力は collect(受け取り)だけです。見送りや、ふつうの販売で代わりにすることはできません。予約の欄から、(本人, 品物, 今日)が一致する1件を外し、2コインと販売数1を足します。自由在庫を、もう一度減らしてはいけません。

お客の列を最後まで進めると close(閉店)になります。Z で家賃2コインを1回だけ払います。お金がマイナスなら、その日のまま end(終わり)です。そうでなければ、次の日の morning になります。ただし、3日目の閉店では4日目に進まず、end になります。3日目には予約したい固定客がいないので、まだ作っていない4日目の受け取りは生まれません。最後の判定は、sold >= 4 かつ cash >= 0 です。売れないまま3日過ごすと、cash が0でも目標は達成できません。

bought は、学習のために足した「これまでに仕入れた数の合計」です。元のゲームのモデルにはない、計算を確かめるための欄で、画面のお金とは別に記録します。「自由在庫 + 予約数 + 販売数 = 最初の2個 + 仕入れの合計」と「お金 = 6 - 仕入れの合計 + 2×販売数 - 2×家賃を払った回数」が、どの状態の変化(遷移)のあとでも成り立つかをテストします。

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

03_tomorrow.py / 注目する部分
    if shelf(s) + sum(buy) > CAPACITY:
        raise ValueError("SHELF FULL INCLUDING RESERVATIONS")
    pickups = tuple((n, i, "pickup", "") for n, i, due in s.reservations if due == s.day)
    fixed = tuple((n, i, kind, "") for n, i, kind in TABLE[s.day - 1])
    queue = pickups + fixed

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_tomorrow.py
macOS / Linux
.venv/bin/python 03_tomorrow.py
画面で見ることZ・Z・Z・Z と押すと、仕入れ0→ハナに販売→レンの予約→閉店と進み、DAY 2 MORNING・COINS 6 になります。上・上・右・上と押すと、仕入れの案が MUG 2・STAR 1 になります。Z を押すと COINS 3、自由在庫は(2,1)、REN の予約が1件です。列は REN PICKUP→HANA BUY→MINA RESERVE です。次の Z で COINS 5・SOLD 2 になり、REN の予約が消えます。
  • 仕入れるときに、予約を含めた棚の数を調べる
  • 受け取りでは、自由在庫を減らさない
  • 家賃と3日目の終わりを、1回だけ処理する
つまずきやすいところ受け取りの前に、予約の品物を棚から外して、仕入れの容量を計算してはいけません。collect のときは予約から外し、自由在庫は減らしません。家賃はお客ごとに払うのではなく、close から1回だけ払います。
少し変えてみよう

朝に調べる棚の容量 CAPACITY を、5から4に変えてみましょう。最初の朝に MUG 3 の案を確定しようとすると断られ、COINS 6 のままです。MUG 2 なら、棚4・COINS 4 で店を開けられます。確かめたら5に戻します。

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

"""あしたも開く店: 第3段階。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace

ITEMS = ("MUG", "STAR")
NAMES = {"ハナ": "HANA", "レン": "REN", "ミナ": "MINA"}
SELL_PRICE = 2


def label(name):
    if name.endswith("の友人"):
        return NAMES.get(name[:-3], "GUEST") + " FRIEND"
    return NAMES.get(name, "GUEST")

CAPACITY = 5
RENT = 2
TABLE = (
    (("ハナ", 0, "buy"), ("レン", 1, "reserve")),
    (("ハナ", 1, "buy"), ("ミナ", 0, "reserve")),
    (("レン", 0, "buy"), ("ハナ", 0, "buy")),
)

@dataclass(frozen=True)
class State:
    day: int = 1
    phase: str = "morning"
    cash: int = 6
    free: tuple = (1, 1)
    sold: int = 0
    index: int = 0
    reservations: tuple = ()
    queue: tuple = ()
    bought: int = 0
    rent: int = 0


def initial():
    return State()


def shelf(s):
    return sum(s.free) + len(s.reservations)


def customer(s):
    return s.queue[s.index] if s.phase == "customer" else None


def success(s):
    return s.phase == "end" and s.sold >= 4 and s.cash >= 0


def forecast(s, buy=(0, 0)):
    if s.phase != "morning":
        raise ValueError("STOCK ONLY IN MORNING")
    if type(buy) not in (tuple, list) or len(buy) != 2:
        raise ValueError("USE TWO COUNTS")
    if any(type(n) is not int or not 0 <= n <= 3 for n in buy):
        raise ValueError("COUNTS MUST BE INTEGERS 0..3")
    if sum(buy) > 3:
        raise ValueError("BUY AT MOST 3")
    if sum(buy) > s.cash:
        raise ValueError("NOT ENOUGH COINS")
    if shelf(s) + sum(buy) > CAPACITY:
        raise ValueError("SHELF FULL INCLUDING RESERVATIONS")
    pickups = tuple((n, i, "pickup", "") for n, i, due in s.reservations if due == s.day)
    fixed = tuple((n, i, kind, "") for n, i, kind in TABLE[s.day - 1])
    queue = pickups + fixed
    return {"cost": sum(buy), "free": tuple(s.free[i] + buy[i] for i in (0, 1)),
            "shelf": shelf(s) + sum(buy), "queue": queue}


def actions(s):
    if s.phase == "morning":
        for mug in range(4):
            for star in range(4 - mug):
                try:
                    forecast(s, (mug, star))
                except ValueError:
                    continue
                yield ("stock", mug, star)
    elif s.phase == "close":
        yield ("close",)
    elif s.phase == "customer":
        if customer(s)[2] == "pickup":
            yield ("collect",)
        else:
            yield ("skip",)
            if s.free[customer(s)[1]] > 0:
                yield ("serve",)


def step(s, action):
    if type(action) not in (tuple, list) or not action or type(action[0]) is not str:
        raise ValueError("INVALID ACTION")
    if any(type(n) is not int for n in action[1:]):
        raise ValueError("INVALID ACTION COUNTS")
    action = tuple(action)
    if action not in actions(s):
        raise ValueError("ACTION NOT AVAILABLE")
    if action[0] == "stock":
        f = forecast(s, action[1:])
        return replace(s, cash=s.cash - f["cost"], free=f["free"], queue=f["queue"],
                       index=0, phase="customer", bought=s.bought + f["cost"])
    if action[0] == "close":
        cash = s.cash - RENT
        end = s.day == 3 or cash < 0
        return replace(s, cash=cash, rent=s.rent + 1, day=s.day if end else s.day + 1,
                       phase="end" if end else "morning", queue=(), index=0)
    name, item, kind, origin = customer(s)
    free = list(s.free)
    cash, sold = s.cash, s.sold
    reservations = list(s.reservations)
    if action[0] == "skip":
        pass
    elif kind == "reserve":
        free[item] -= 1
        reservations.append((name, item, s.day + 1))
    else:
        if kind == "pickup":
            reservations.remove((name, item, s.day))
        else:
            free[item] -= 1
        cash += SELL_PRICE
        sold += 1
    index = s.index + 1
    return replace(s, free=tuple(free), cash=cash, sold=sold, index=index,
                   phase="close" if index == len(s.queue) else "customer",
                   reservations=tuple(reservations))

class App:
    """The pure model is also usable without importing Pyxel."""
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.stock = [0, 0]
        self.item = 0
        self.previous = set()
        self.gated = False
        self.message = "READY"
        self.history = []

    def apply(self, action):
        try:
            after = step(self.state, action)
        except ValueError as error:
            self.message = str(error)
            return False
        # Undo is introduced in stage 5.
        self.state = after
        self.stock = [0, 0]
        self.message = action[0].upper()
        return True

    def control(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gated:
            if not down:
                self.gated = False
            return
        if not fresh:
            return
        self.gated = True
        if len(down) != 1:
            self.message = "RELEASE ALL KEYS"
            return
        key = next(iter(fresh))
        if key == "R":
            self.reset()
            self.gated = True
            self.previous = down
            return
        
        s = self.state
        if s.phase == "end":
            self.message = "ENDED / R TO RESET"
            return
        if s.phase == "morning":
            if key in ("LEFT", "RIGHT"):
                self.item = 1 - self.item
            elif key in ("UP", "DOWN"):
                delta = 1 if key == "UP" else -1
                self.stock[self.item] = max(0, min(3, self.stock[self.item] + delta))
            elif key == "Z":
                self.apply(("stock", *self.stock))
            return
        if s.phase == "customer":
            if key == "Z":
                self.apply(("collect",) if customer(s)[2] == "pickup" else ("serve",))
            elif key == "X":
                self.apply(("skip",))
        elif s.phase == "close" and key == "Z":
            self.apply(("close",))

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(10, 10, "TOMORROW'S SHOP / STAGE 3", 10)
        day = getattr(s, "day", 1)
        p.text(10, 26, "DAY %d  %s" % (day, s.phase.upper()), 7)
        p.text(10, 40, "COINS %d   SOLD %d   OWNED %d" % (s.cash, s.sold, shelf(s)), 11)
        p.text(10, 55, "FICTIONAL COINS / NO REAL PURCHASES", 5)
        p.rectb(8, 70, 210, 105, 5)
        p.rectb(228, 70, 212, 105, 5)
        p.text(16, 78, "FREE STOCK", 6)
        for item in (0, 1):
            y = 98 + item * 28
            p.text(16, y, ITEMS[item], 7)
            for n in range(s.free[item]):
                x = 60 + n * 20
                if item == 0:
                    p.rect(x, y - 3, 12, 12, 12)
                    p.rectb(x + 10, y, 5, 6, 12)
                else:
                    p.rect(x, y - 3, 10, 15, 10)
                    p.text(x + 3, y + 1, "*", 1)
            p.text(174, y, "x%d" % s.free[item], 7)
        p.text(16, 156, "OWNED = FREE + RESERVED", 6)
        p.text(236, 78, "NAMED RESERVATIONS", 6)
        reservations = getattr(s, "reservations", ())
        for row, (name, item, due) in enumerate(reservations):
            p.text(236, 95 + row * 13, "%s %s D%d" % (label(name), ITEMS[item], due), 10)
        if not reservations:
            p.text(236, 98, "NONE", 5)
        p.text(10, 187, "CUSTOMER QUEUE / ARROW = CURRENT", 7)
        queue = s.queue if hasattr(s, "queue") else QUEUE
        for row, (name, item, kind, origin) in enumerate(queue):
            prefix = "> " if row == s.index and s.phase == "customer" else "  "
            line = prefix + label(name) + " / " + ITEMS[item] + " / " + kind.upper()
            p.text(10, 202 + row * 13, line, 10 if prefix == "> " else 6)
        if s.phase == "morning":
            p.text(10, 202, "BUY MUG %d  STAR %d / SELECT %s" % (*self.stock, ITEMS[self.item]), 10)
            p.text(10, 219, "RESERVATIONS STILL OCCUPY SHELF SPACE", 6)
            p.text(10, 235, "LEFT/RIGHT ITEM  UP/DOWN COUNT  Z OPEN", 7)
        if s.phase == "close":
            p.text(10, 260, "Z CLOSE / PAY 2 COINS", 10)
        elif s.phase == "end":
            end_text = "GOAL MET" if success(s) else "REVIEW YOUR PLAN"
            p.text(10, 260, end_text, 10)
        
        p.text(10, 287, self.message[:70], 11)
        p.text(10, 304, "Z SERVE/OPEN/CLOSE  X SKIP  R RESET  ESC QUIT", 6)

KEYS = ("LEFT", "RIGHT", "UP", "DOWN", "Z", "X", "U", "R")
DEMO_KEYS = ('Z', 'Z', 'Z', 'Z', 'UP', 'UP', 'RIGHT', 'UP', 'Z')


def parse_args(argv=None):
    """Validate all bounded QA flags before any Pyxel import or initialization."""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error("--frames must be between %d and 600" % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir():
            parser.error("screenshot must be a NEW .png file in an existing directory")
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    from dataclasses import asdict
    import pyxel as p
    app = App()
    p.init(448, 320, title="Tomorrow's Shop / lesson 3", fps=30,
           display_scale=2, headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                p.screenshot(args.screenshot, scale=2)
            print(json.dumps({"state": asdict(app.state), "history_length": len(app.history)}, ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            index = p.frame_count // 2
            down = {DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keys.items() if p.btn(value)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 04 / 05

先頭受取客の友人を、固定客の後へ加える

毎回同じに決まるお客の列、反対の商品をほしがる友だち、人ごとの記憶。

元のゲームの場面Aの、紹介ありのルールを加えます。朝に受け取りの予定がある人がいれば、その先頭の1人の友だちだけが、固定客2人のうしろに来ます。友だちは、受け取る本人が希望した品物とは反対の商品を1つほしがります。商品は0か1だけなので、1 - item で反対になります。栞(1)を予約したレンの友だちはマグ(0)を、マグ(0)を予約したミナの友だちは栞(1)をほしがります。

紹介は、受け取りの瞬間にランダムで足すものではありません。朝に pickups + fixed + friends の列を作ります。受け取りは必ず行うルールなので、本人の取引が先に終わります。列は最初から分かるので、仕入れの計画に使えます。同じ日に予約が何件あっても、紹介は先頭の1人だけです。受け取るお客全員に友だちを付ける作り方に変えると、別のルールになってしまいます。

State.memory は、人ごとの(名前, 来店して処理した回数, 最後に買った商品, 予約を受けた回数, 予約を見送った回数, 紹介元)の並びです。初めて来た人の「最後に買った商品」は-1で、まだ買っていないことを表します。ふつうの購入と受け取りでは、最後に買った商品を更新します。予約したときは予約を受けた回数を、予約を断ったときは見送った回数を増やします。友だちには、紹介元(origin)が残ります。一時的に dict(辞書)を使って同じ名前の記録を置きかえ、最後に名前順のタプルに戻します。

画面の PAST VISITS は、今のお客を処理する前の履歴です。2日目の受け取りのすぐあとにハナが前に来ると、PAST VISITS 1 / BOUGHT MUG と表示されます。履歴が、お客の列を勝手に増やすことはありません。見送っても、明日来る固定客は TABLE のとおりです。元のゲームでは、この記録から「前の品を使っている」「紹介で来た」などの日本語のせりふを選びます。この教材では、数字を表示するところまでにしています。

目標を達成する方法は、予約だけではありません。予約を断って、固定客に4個売る道すじもあります。この教材のコインと紹介のルールは、現実の売上・信用・経営の成績を予想したり、約束したりするものではありません。

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

04_referrals.py / 注目する部分
    friends = ((pickups[0][0] + "の友人", 1 - pickups[0][1], "buy", pickups[0][0]),) if pickups else ()
    queue = pickups + fixed + friends

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_referrals.py
macOS / Linux
.venv/bin/python 04_referrals.py
画面で見ること03と同じように2日目の店を開けると、列は REN PICKUP→HANA BUY→MINA RESERVE→REN FRIEND MUG BUY になります。COINS 3・予約 REN STAR D2 の状態から Z を4回押すと、COINS 9・SOLD 4・MINA MUG D3 が1件・自由在庫(0,0)の CLOSE になります。
  • 紹介されるのは、先頭の受け取り客の友だち1人だけ
  • 友だちは固定客のあとに来て、反対の商品をほしがる
  • 来店の記憶と、お客の列を作る処理を分ける
つまずきやすいところ友だちを、受け取りや固定客より前に並べてはいけません。「1 - 商品番号」を忘れると、友だちが本人と同じ品物をほしがり、必要な仕入れが変わってしまいます。紹介のない03でも、本人の受け取りは消しません。
少し変えてみよう

03と04で、2日目に店を開けたときの列を比べてみましょう。どちらも、先頭のレンは受け取りで、次はハナとミナです。04だけ、最後にレンの友だちが1人加わり、商品番号0のマグをほしがります。3日目は先頭がミナなので、友だちがほしがるのは番号1の栞です。

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

"""あしたも開く店: 第4段階。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace

ITEMS = ("MUG", "STAR")
NAMES = {"ハナ": "HANA", "レン": "REN", "ミナ": "MINA"}
SELL_PRICE = 2


def label(name):
    if name.endswith("の友人"):
        return NAMES.get(name[:-3], "GUEST") + " FRIEND"
    return NAMES.get(name, "GUEST")

CAPACITY = 5
RENT = 2
TABLE = (
    (("ハナ", 0, "buy"), ("レン", 1, "reserve")),
    (("ハナ", 1, "buy"), ("ミナ", 0, "reserve")),
    (("レン", 0, "buy"), ("ハナ", 0, "buy")),
)

@dataclass(frozen=True)
class State:
    day: int = 1
    phase: str = "morning"
    cash: int = 6
    free: tuple = (1, 1)
    sold: int = 0
    index: int = 0
    reservations: tuple = ()
    queue: tuple = ()
    bought: int = 0
    rent: int = 0
    memory: tuple = ()


def initial():
    return State()


def shelf(s):
    return sum(s.free) + len(s.reservations)


def customer(s):
    return s.queue[s.index] if s.phase == "customer" else None


def memory_for(s, name):
    return next((m for m in s.memory if m[0] == name), (name, 0, -1, 0, 0, ""))


def success(s):
    return s.phase == "end" and s.sold >= 4 and s.cash >= 0


def forecast(s, buy=(0, 0)):
    if s.phase != "morning":
        raise ValueError("STOCK ONLY IN MORNING")
    if type(buy) not in (tuple, list) or len(buy) != 2:
        raise ValueError("USE TWO COUNTS")
    if any(type(n) is not int or not 0 <= n <= 3 for n in buy):
        raise ValueError("COUNTS MUST BE INTEGERS 0..3")
    if sum(buy) > 3:
        raise ValueError("BUY AT MOST 3")
    if sum(buy) > s.cash:
        raise ValueError("NOT ENOUGH COINS")
    if shelf(s) + sum(buy) > CAPACITY:
        raise ValueError("SHELF FULL INCLUDING RESERVATIONS")
    pickups = tuple((n, i, "pickup", "") for n, i, due in s.reservations if due == s.day)
    fixed = tuple((n, i, kind, "") for n, i, kind in TABLE[s.day - 1])
    friends = ((pickups[0][0] + "の友人", 1 - pickups[0][1], "buy", pickups[0][0]),) if pickups else ()
    queue = pickups + fixed + friends
    return {"cost": sum(buy), "free": tuple(s.free[i] + buy[i] for i in (0, 1)),
            "shelf": shelf(s) + sum(buy), "queue": queue}


def actions(s):
    if s.phase == "morning":
        for mug in range(4):
            for star in range(4 - mug):
                try:
                    forecast(s, (mug, star))
                except ValueError:
                    continue
                yield ("stock", mug, star)
    elif s.phase == "close":
        yield ("close",)
    elif s.phase == "customer":
        if customer(s)[2] == "pickup":
            yield ("collect",)
        else:
            yield ("skip",)
            if s.free[customer(s)[1]] > 0:
                yield ("serve",)


def step(s, action):
    if type(action) not in (tuple, list) or not action or type(action[0]) is not str:
        raise ValueError("INVALID ACTION")
    if any(type(n) is not int for n in action[1:]):
        raise ValueError("INVALID ACTION COUNTS")
    action = tuple(action)
    if action not in actions(s):
        raise ValueError("ACTION NOT AVAILABLE")
    if action[0] == "stock":
        f = forecast(s, action[1:])
        return replace(s, cash=s.cash - f["cost"], free=f["free"], queue=f["queue"],
                       index=0, phase="customer", bought=s.bought + f["cost"])
    if action[0] == "close":
        cash = s.cash - RENT
        end = s.day == 3 or cash < 0
        return replace(s, cash=cash, rent=s.rent + 1, day=s.day if end else s.day + 1,
                       phase="end" if end else "morning", queue=(), index=0)
    name, item, kind, origin = customer(s)
    free = list(s.free)
    cash, sold = s.cash, s.sold
    reservations = list(s.reservations)
    memory = list(memory_for(s, name))
    memory[1] += 1
    if origin:
        memory[5] = origin
    if action[0] == "skip":
        if kind == "reserve":
            memory[4] += 1
    elif kind == "reserve":
        free[item] -= 1
        reservations.append((name, item, s.day + 1))
        memory[3] += 1
    else:
        if kind == "pickup":
            reservations.remove((name, item, s.day))
        else:
            free[item] -= 1
        cash += SELL_PRICE
        sold += 1
        memory[2] = item
    memories = {m[0]: m for m in s.memory}
    memories[name] = tuple(memory)
    index = s.index + 1
    return replace(s, free=tuple(free), cash=cash, sold=sold, index=index,
                   phase="close" if index == len(s.queue) else "customer",
                   reservations=tuple(reservations), memory=tuple(sorted(memories.values())))

class App:
    """The pure model is also usable without importing Pyxel."""
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.stock = [0, 0]
        self.item = 0
        self.previous = set()
        self.gated = False
        self.message = "READY"
        self.history = []

    def apply(self, action):
        try:
            after = step(self.state, action)
        except ValueError as error:
            self.message = str(error)
            return False
        # Undo is introduced in stage 5.
        self.state = after
        self.stock = [0, 0]
        self.message = action[0].upper()
        return True

    def control(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gated:
            if not down:
                self.gated = False
            return
        if not fresh:
            return
        self.gated = True
        if len(down) != 1:
            self.message = "RELEASE ALL KEYS"
            return
        key = next(iter(fresh))
        if key == "R":
            self.reset()
            self.gated = True
            self.previous = down
            return
        
        s = self.state
        if s.phase == "end":
            self.message = "ENDED / R TO RESET"
            return
        if s.phase == "morning":
            if key in ("LEFT", "RIGHT"):
                self.item = 1 - self.item
            elif key in ("UP", "DOWN"):
                delta = 1 if key == "UP" else -1
                self.stock[self.item] = max(0, min(3, self.stock[self.item] + delta))
            elif key == "Z":
                self.apply(("stock", *self.stock))
            return
        if s.phase == "customer":
            if key == "Z":
                self.apply(("collect",) if customer(s)[2] == "pickup" else ("serve",))
            elif key == "X":
                self.apply(("skip",))
        elif s.phase == "close" and key == "Z":
            self.apply(("close",))

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(10, 10, "TOMORROW'S SHOP / STAGE 4", 10)
        day = getattr(s, "day", 1)
        p.text(10, 26, "DAY %d  %s" % (day, s.phase.upper()), 7)
        p.text(10, 40, "COINS %d   SOLD %d   OWNED %d" % (s.cash, s.sold, shelf(s)), 11)
        p.text(10, 55, "FICTIONAL COINS / NO REAL PURCHASES", 5)
        p.rectb(8, 70, 210, 105, 5)
        p.rectb(228, 70, 212, 105, 5)
        p.text(16, 78, "FREE STOCK", 6)
        for item in (0, 1):
            y = 98 + item * 28
            p.text(16, y, ITEMS[item], 7)
            for n in range(s.free[item]):
                x = 60 + n * 20
                if item == 0:
                    p.rect(x, y - 3, 12, 12, 12)
                    p.rectb(x + 10, y, 5, 6, 12)
                else:
                    p.rect(x, y - 3, 10, 15, 10)
                    p.text(x + 3, y + 1, "*", 1)
            p.text(174, y, "x%d" % s.free[item], 7)
        p.text(16, 156, "OWNED = FREE + RESERVED", 6)
        p.text(236, 78, "NAMED RESERVATIONS", 6)
        reservations = getattr(s, "reservations", ())
        for row, (name, item, due) in enumerate(reservations):
            p.text(236, 95 + row * 13, "%s %s D%d" % (label(name), ITEMS[item], due), 10)
        if not reservations:
            p.text(236, 98, "NONE", 5)
        p.text(10, 187, "CUSTOMER QUEUE / ARROW = CURRENT", 7)
        queue = s.queue if hasattr(s, "queue") else QUEUE
        for row, (name, item, kind, origin) in enumerate(queue):
            prefix = "> " if row == s.index and s.phase == "customer" else "  "
            line = prefix + label(name) + " / " + ITEMS[item] + " / " + kind.upper()
            p.text(10, 202 + row * 13, line, 10 if prefix == "> " else 6)
        if s.phase == "morning":
            p.text(10, 202, "BUY MUG %d  STAR %d / SELECT %s" % (*self.stock, ITEMS[self.item]), 10)
            p.text(10, 219, "RESERVATIONS STILL OCCUPY SHELF SPACE", 6)
            p.text(10, 235, "LEFT/RIGHT ITEM  UP/DOWN COUNT  Z OPEN", 7)
        if s.phase == "close":
            p.text(10, 260, "Z CLOSE / PAY 2 COINS", 10)
        elif s.phase == "end":
            end_text = "GOAL MET" if success(s) else "REVIEW YOUR PLAN"
            p.text(10, 260, end_text, 10)
        c = customer(s)
        if c:
            m = memory_for(s, c[0])
            p.text(238, 260, "PAST VISITS %d / BOUGHT %s" % (m[1], "-" if m[2] < 0 else ITEMS[m[2]]), 6)
        p.text(10, 287, self.message[:70], 11)
        p.text(10, 304, "Z SERVE/OPEN/CLOSE  X SKIP  R RESET  ESC QUIT", 6)

KEYS = ("LEFT", "RIGHT", "UP", "DOWN", "Z", "X", "U", "R")
DEMO_KEYS = ('Z', 'Z', 'Z', 'Z', 'UP', 'UP', 'RIGHT', 'UP', 'Z')


def parse_args(argv=None):
    """Validate all bounded QA flags before any Pyxel import or initialization."""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error("--frames must be between %d and 600" % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir():
            parser.error("screenshot must be a NEW .png file in an existing directory")
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    from dataclasses import asdict
    import pyxel as p
    app = App()
    p.init(448, 320, title="Tomorrow's Shop / lesson 4", fps=30,
           display_scale=2, headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                p.screenshot(args.screenshot, scale=2)
            print(json.dumps({"state": asdict(app.state), "history_length": len(app.history)}, ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            index = p.frame_count // 2
            down = {DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keys.items() if p.btn(value)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
STEP 05 / 05

所有品と約束も含め、一つ前の判断へ戻す

状態全体を履歴に残す、失敗と下書きは記録しない。

U で、直前に確定した判断を1つ戻せるようにします。history に操作する前の State を積み、pop で取り出して戻します。State は書き換えられないので、あとで別の販売をしても、過去の箱の中身は変わりません。自由在庫だけでなく、予約した人、受け取る日、お金、販売数、仕入れの合計、家賃を払った回数、お客の列、今の位置、人ごとの記憶もいっしょに戻します。

apply では、先に step を試します。ValueError になったら、そこで処理を終えます。成功してから、history に前の State を足します。仕入れの案を上下・左右で変える操作は、まだ判断を確定していないので、履歴に積みません。断られた仕入れを積むと、U を押しても何も変わらない履歴ばかりになってしまいます。プログラムでは、1回の意味入力を、全部まとめた1つの変更として扱います。

U の処理は、end で操作を断る処理より前に置きます。そのため、3日目の結果や、家賃が払えずに店を閉じた状態からも、1つ前に戻れます。閉店を戻すと、日付だけでなく、払った家賃と、空にしたお客の列も戻ります。受け取りを戻すときは、販売数とお金を減らすだけでは足りません。元の名前付きの予約と、その日の列の index も元に戻します。もう一度 Z で同じ受け取りをすると、同じ結果に進めます。

R は、状態・仕入れの案・選んでいる商品・履歴を、全部最初に戻します。U は練習のために過去へ巻き戻す操作で、店の世界でお客の予約を取り消す操作ではありません。この教材では Undo のキーを U にして、X は見送りのままにしています。元のゲームでは、左右で選び、Enter で確認の画面を開いてもう一度 Enter、X で Undo です。操作のしかたがちがうので、まちがえないようにしましょう。

ファイルへの保存と読み込みは作りません。画面を閉じると history はなくなります。元のゲームの model.undo は、意味入力の history を1つ消して、new から再生(最初から同じ入力を流し直すこと)します。restore は、保存した状態と、再生した結果と、型が一致するかを調べます。この教材は、小さな書き換えない State を直接積むやり方です。次に元のゲームを読むときは、この2種類の Undo が、どちらも同じ「判断の区切り」で戻る理由を比べてみましょう。

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

05_undo.py / 注目する部分
        self.history.append(self.state)
        self.state = after
        self.stock = [0, 0]

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_undo.py
macOS / Linux
.venv/bin/python 05_undo.py
画面で見ること04と同じ2日目の開店状態から Z を押してレンが受け取ると、COINS 5・SOLD 2・予約 NONE になります。U を押すと COINS 3・SOLD 1・REN STAR D2 に戻り、矢印も REN に戻ります。もう一度 Z を押すと、前と同じ受け取りの結果になります。自動チェックポイント(自動で確かめる場面)は、この受け取り→U まで進めた状態です。
  • 状態全体をまとめて Undo する
  • 断られた操作と仕入れの案は、履歴に入れない
  • 終わったあとも戻れて、R は履歴も消す
つまずきやすいところcash と free だけを戻すと、予約や記憶が二重に残ってしまいます。書き換えられる list を、そのまま履歴と共有すると、過去まで書き換わります。書き換えない State 全体を記録し、案や断られた操作と、確定した操作を区別します。
少し変えてみよう

仕入れ0→ハナに販売→レンの予約→閉店と進めて、DAY 2 MORNING にしてみましょう。U を押すと、DAY 1 CLOSE・COINS 8・家賃を払った回数0に戻り、REN STAR D2 も残っています。さらに U を押すと予約の直前に戻り、自由在庫の STAR x1・予約 NONE・COINS 8 になります。

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

"""あしたも開く店: 第5段階。Python 3.11+ / Pyxel 2.9.9。"""
from dataclasses import dataclass, replace

ITEMS = ("MUG", "STAR")
NAMES = {"ハナ": "HANA", "レン": "REN", "ミナ": "MINA"}
SELL_PRICE = 2


def label(name):
    if name.endswith("の友人"):
        return NAMES.get(name[:-3], "GUEST") + " FRIEND"
    return NAMES.get(name, "GUEST")

CAPACITY = 5
RENT = 2
TABLE = (
    (("ハナ", 0, "buy"), ("レン", 1, "reserve")),
    (("ハナ", 1, "buy"), ("ミナ", 0, "reserve")),
    (("レン", 0, "buy"), ("ハナ", 0, "buy")),
)

@dataclass(frozen=True)
class State:
    day: int = 1
    phase: str = "morning"
    cash: int = 6
    free: tuple = (1, 1)
    sold: int = 0
    index: int = 0
    reservations: tuple = ()
    queue: tuple = ()
    bought: int = 0
    rent: int = 0
    memory: tuple = ()


def initial():
    return State()


def shelf(s):
    return sum(s.free) + len(s.reservations)


def customer(s):
    return s.queue[s.index] if s.phase == "customer" else None


def memory_for(s, name):
    return next((m for m in s.memory if m[0] == name), (name, 0, -1, 0, 0, ""))


def success(s):
    return s.phase == "end" and s.sold >= 4 and s.cash >= 0


def forecast(s, buy=(0, 0)):
    if s.phase != "morning":
        raise ValueError("STOCK ONLY IN MORNING")
    if type(buy) not in (tuple, list) or len(buy) != 2:
        raise ValueError("USE TWO COUNTS")
    if any(type(n) is not int or not 0 <= n <= 3 for n in buy):
        raise ValueError("COUNTS MUST BE INTEGERS 0..3")
    if sum(buy) > 3:
        raise ValueError("BUY AT MOST 3")
    if sum(buy) > s.cash:
        raise ValueError("NOT ENOUGH COINS")
    if shelf(s) + sum(buy) > CAPACITY:
        raise ValueError("SHELF FULL INCLUDING RESERVATIONS")
    pickups = tuple((n, i, "pickup", "") for n, i, due in s.reservations if due == s.day)
    fixed = tuple((n, i, kind, "") for n, i, kind in TABLE[s.day - 1])
    friends = ((pickups[0][0] + "の友人", 1 - pickups[0][1], "buy", pickups[0][0]),) if pickups else ()
    queue = pickups + fixed + friends
    return {"cost": sum(buy), "free": tuple(s.free[i] + buy[i] for i in (0, 1)),
            "shelf": shelf(s) + sum(buy), "queue": queue}


def actions(s):
    if s.phase == "morning":
        for mug in range(4):
            for star in range(4 - mug):
                try:
                    forecast(s, (mug, star))
                except ValueError:
                    continue
                yield ("stock", mug, star)
    elif s.phase == "close":
        yield ("close",)
    elif s.phase == "customer":
        if customer(s)[2] == "pickup":
            yield ("collect",)
        else:
            yield ("skip",)
            if s.free[customer(s)[1]] > 0:
                yield ("serve",)


def step(s, action):
    if type(action) not in (tuple, list) or not action or type(action[0]) is not str:
        raise ValueError("INVALID ACTION")
    if any(type(n) is not int for n in action[1:]):
        raise ValueError("INVALID ACTION COUNTS")
    action = tuple(action)
    if action not in actions(s):
        raise ValueError("ACTION NOT AVAILABLE")
    if action[0] == "stock":
        f = forecast(s, action[1:])
        return replace(s, cash=s.cash - f["cost"], free=f["free"], queue=f["queue"],
                       index=0, phase="customer", bought=s.bought + f["cost"])
    if action[0] == "close":
        cash = s.cash - RENT
        end = s.day == 3 or cash < 0
        return replace(s, cash=cash, rent=s.rent + 1, day=s.day if end else s.day + 1,
                       phase="end" if end else "morning", queue=(), index=0)
    name, item, kind, origin = customer(s)
    free = list(s.free)
    cash, sold = s.cash, s.sold
    reservations = list(s.reservations)
    memory = list(memory_for(s, name))
    memory[1] += 1
    if origin:
        memory[5] = origin
    if action[0] == "skip":
        if kind == "reserve":
            memory[4] += 1
    elif kind == "reserve":
        free[item] -= 1
        reservations.append((name, item, s.day + 1))
        memory[3] += 1
    else:
        if kind == "pickup":
            reservations.remove((name, item, s.day))
        else:
            free[item] -= 1
        cash += SELL_PRICE
        sold += 1
        memory[2] = item
    memories = {m[0]: m for m in s.memory}
    memories[name] = tuple(memory)
    index = s.index + 1
    return replace(s, free=tuple(free), cash=cash, sold=sold, index=index,
                   phase="close" if index == len(s.queue) else "customer",
                   reservations=tuple(reservations), memory=tuple(sorted(memories.values())))

class App:
    """The pure model is also usable without importing Pyxel."""
    def __init__(self):
        self.reset()

    def reset(self):
        self.state = initial()
        self.stock = [0, 0]
        self.item = 0
        self.previous = set()
        self.gated = False
        self.message = "READY"
        self.history = []

    def apply(self, action):
        try:
            after = step(self.state, action)
        except ValueError as error:
            self.message = str(error)
            return False
        self.history.append(self.state)
        self.state = after
        self.stock = [0, 0]
        self.message = action[0].upper()
        return True

    def control(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gated:
            if not down:
                self.gated = False
            return
        if not fresh:
            return
        self.gated = True
        if len(down) != 1:
            self.message = "RELEASE ALL KEYS"
            return
        key = next(iter(fresh))
        if key == "R":
            self.reset()
            self.gated = True
            self.previous = down
            return
        if key == "U":
            if self.history:
                self.state = self.history.pop()
                self.stock = [0, 0]
                self.message = "UNDO"
            else:
                self.message = "NOTHING TO UNDO"
            return
        s = self.state
        if s.phase == "end":
            self.message = "ENDED / R TO RESET"
            return
        if s.phase == "morning":
            if key in ("LEFT", "RIGHT"):
                self.item = 1 - self.item
            elif key in ("UP", "DOWN"):
                delta = 1 if key == "UP" else -1
                self.stock[self.item] = max(0, min(3, self.stock[self.item] + delta))
            elif key == "Z":
                self.apply(("stock", *self.stock))
            return
        if s.phase == "customer":
            if key == "Z":
                self.apply(("collect",) if customer(s)[2] == "pickup" else ("serve",))
            elif key == "X":
                self.apply(("skip",))
        elif s.phase == "close" and key == "Z":
            self.apply(("close",))

    def draw(self, p):
        s = self.state
        p.cls(1)
        p.text(10, 10, "TOMORROW'S SHOP / STAGE 5", 10)
        day = getattr(s, "day", 1)
        p.text(10, 26, "DAY %d  %s" % (day, s.phase.upper()), 7)
        p.text(10, 40, "COINS %d   SOLD %d   OWNED %d" % (s.cash, s.sold, shelf(s)), 11)
        p.text(10, 55, "FICTIONAL COINS / NO REAL PURCHASES", 5)
        p.rectb(8, 70, 210, 105, 5)
        p.rectb(228, 70, 212, 105, 5)
        p.text(16, 78, "FREE STOCK", 6)
        for item in (0, 1):
            y = 98 + item * 28
            p.text(16, y, ITEMS[item], 7)
            for n in range(s.free[item]):
                x = 60 + n * 20
                if item == 0:
                    p.rect(x, y - 3, 12, 12, 12)
                    p.rectb(x + 10, y, 5, 6, 12)
                else:
                    p.rect(x, y - 3, 10, 15, 10)
                    p.text(x + 3, y + 1, "*", 1)
            p.text(174, y, "x%d" % s.free[item], 7)
        p.text(16, 156, "OWNED = FREE + RESERVED", 6)
        p.text(236, 78, "NAMED RESERVATIONS", 6)
        reservations = getattr(s, "reservations", ())
        for row, (name, item, due) in enumerate(reservations):
            p.text(236, 95 + row * 13, "%s %s D%d" % (label(name), ITEMS[item], due), 10)
        if not reservations:
            p.text(236, 98, "NONE", 5)
        p.text(10, 187, "CUSTOMER QUEUE / ARROW = CURRENT", 7)
        queue = s.queue if hasattr(s, "queue") else QUEUE
        for row, (name, item, kind, origin) in enumerate(queue):
            prefix = "> " if row == s.index and s.phase == "customer" else "  "
            line = prefix + label(name) + " / " + ITEMS[item] + " / " + kind.upper()
            p.text(10, 202 + row * 13, line, 10 if prefix == "> " else 6)
        if s.phase == "morning":
            p.text(10, 202, "BUY MUG %d  STAR %d / SELECT %s" % (*self.stock, ITEMS[self.item]), 10)
            p.text(10, 219, "RESERVATIONS STILL OCCUPY SHELF SPACE", 6)
            p.text(10, 235, "LEFT/RIGHT ITEM  UP/DOWN COUNT  Z OPEN", 7)
        if s.phase == "close":
            p.text(10, 260, "Z CLOSE / PAY 2 COINS", 10)
        elif s.phase == "end":
            end_text = "GOAL MET" if success(s) else "REVIEW YOUR PLAN"
            p.text(10, 260, end_text, 10)
        c = customer(s)
        if c:
            m = memory_for(s, c[0])
            p.text(238, 260, "PAST VISITS %d / BOUGHT %s" % (m[1], "-" if m[2] < 0 else ITEMS[m[2]]), 6)
        p.text(10, 287, self.message[:70], 11)
        p.text(10, 304, "Z SERVE/OPEN/CLOSE  X SKIP  U UNDO  R RESET  ESC QUIT", 6)

KEYS = ("LEFT", "RIGHT", "UP", "DOWN", "Z", "X", "U", "R")
DEMO_KEYS = ('Z', 'Z', 'Z', 'Z', 'UP', 'UP', 'RIGHT', 'UP', 'Z', 'Z', 'U')


def parse_args(argv=None):
    """Validate all bounded QA flags before any Pyxel import or initialization."""
    import argparse
    from pathlib import Path
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--headless-smoke", action="store_true")
    parser.add_argument("--demo-checkpoint", action="store_true")
    parser.add_argument("--frames", type=int)
    parser.add_argument("--screenshot")
    args = parser.parse_args(argv)
    if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke:
        parser.error("QA options require --headless-smoke")
    minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8
    if args.frames is None:
        args.frames = minimum
    if not minimum <= args.frames <= 600:
        parser.error("--frames must be between %d and 600" % minimum)
    if args.screenshot:
        path = Path(args.screenshot).expanduser().resolve()
        if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir():
            parser.error("screenshot must be a NEW .png file in an existing directory")
        args.screenshot = str(path)
    return args


def main(argv=None):
    args = parse_args(argv)
    import json
    from dataclasses import asdict
    import pyxel as p
    app = App()
    p.init(448, 320, title="Tomorrow's Shop / lesson 5", fps=30,
           display_scale=2, headless=args.headless_smoke)
    keys = {key: getattr(p, "KEY_" + key) for key in KEYS}
    def update():
        if args.headless_smoke and p.frame_count >= args.frames:
            if args.screenshot:
                p.screenshot(args.screenshot, scale=2)
            print(json.dumps({"state": asdict(app.state), "history_length": len(app.history)}, ensure_ascii=True), flush=True)
            p.quit()
            return
        if args.headless_smoke:
            index = p.frame_count // 2
            down = {DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set()
        else:
            down = {key for key, value in keys.items() if p.btn(value)}
        app.control(down)
    p.run(update, lambda: app.draw(p))


if __name__ == "__main__":
    main()
AFTER STEP 05

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

元のゲームの場面Aの3日間です。最初は6コインと、マグと栞が1個ずつです。仕入れは1個1コイン、販売は2コイン、家賃は1日2コインです。棚は予約を含めて5つ分、1回の朝の仕入れは合計3個までです。予約は、今ある品物に名札を付けて取っておき、翌朝に必ず受け取りに来ます。列は、受け取り→固定客2人→先頭の受け取り客の友だち1人(反対の商品をほしがる)の順です。3日目の家賃を払ったあとに、4個以上売れていて、お金が0以上なら達成です。

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

比べる点5ステップの教材元の完成版
段階01は2人への販売だけ、02は1日目の予約まで、03は場面Aの紹介なし、04と05は場面Aの紹介あり。A・B・Cの固定客と、紹介ありとなしの比較。
操作Z ですぐ確定、X で見送り、05は U で Undo。キーを全部離すまで待ち、同時押しは受け付けない。確認の画面はない。左右で選び、Enter を2回押して確認する。X で Undo。タイトル画面・一時停止・詳しい表示・確認の画面がある。
表示と履歴英字(ASCII)の図形と英語のラベル。内部の名前は日本語。05は書き換えない State の写し(スナップショット)を残し、保存はない。仕入れの合計 bought を、計算を確かめるために追加。日本語の文字とせりふ。意味入力を再生する Undo。再生した結果が正確に一致するかを調べる、保存と復元。

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

  1. model.py の forecast(仕入れのときの棚のチェックと、pickups + fixed + friends の列)
  2. model.py の step(予約・受け取り・家賃・memory)
  3. model.py の undo・replay・restore(再生と、型が合っているかの確認)
  4. controller.py の feed・selected(押し直し・確認と意味入力)
  5. experiments.py の SHOW・NO_RES・explore(限られた数の計画を比べる)
自分のゲームに変えるなら

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

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