パズルLESSON 57提示額・店主の記憶・隣在庫の架空市場原作038

うそつき市場の作り方

見た値段はそのまま固定して、次にどう変わるかを読みます。
品物が6つだけの架空の市場で、財布と品物の持ち主、店主が一度だけ変える値段、となりの店の在庫から生まれる価値を扱います。

完成版の遊び方6つの品物がある架空の市場で、買う順番を選びます。店主が一度だけ変える値段、となりの店の在庫、袋の空きを見て決めます。

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

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

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

完成版の主な操作:左右キーで店を選ぶ、上下キーで品を選ぶ、Zで値段の提示を開き、もう一度Zで買う、Xで見送る、Spaceで帰宅の確認を開き、Zで帰る、Pで一時停止、Rで再開

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

画面に「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
知っておくとよいこと
変数・if・for・関数 / 書き換えない State・タプル・辞書(共通の入門を参照) / 足し算と、真偽(True・False)のOR
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
左右・1〜6:在庫の品を選ぶ / Z:01はその場で買う、02〜05は値段の提示→もう一度押して買う / X:提示を見送る・帰宅の確認を取り消す(Undoはなし) / F:提示がないとき、帰宅の確認を開く / R:最初に戻す(新しく押したRを優先) / Esc:終了
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_wallet.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

財布・在庫・袋を一度に更新する

限られた品物の持ち主と、お金の合計が変わらないこと。

架空の店がA・B・Cの3つあり、それぞれに茶とジャムが1個ずつあります。まず、値段は BASE(もとの値段)のまま固定します。数字1〜6か左右キーで品を選び、Zで買います。最初の持ち金は10で、袋には3個まで入ります。現実のお金や投資は扱いません。6つの品だけで遊ぶ、ルールの練習です。

State に、cash(持ち金)・stock(店の在庫)・bag(袋)・spent(使ったお金)をまとめます。買えたときは、4つのことを一緒に行います。財布から値段を引くこと、使ったお金に同じ額を足すこと、店の在庫を False(なし)にすること、袋に(店, 品)を加えることです。validate で、cash+spent==INITIAL_CASH と「残りの在庫の数+袋の数=6」が、いつも保たれているかを確かめます。

袋には、品の名前だけでなく、どの店で買ったか(出身の店)も残します。あとの段階で、「となりの店の在庫」が品の価値に関わるためです。今は、どの品も価値はいつも3です。total(合計)は「残りのお金+袋の価値」です。払ったお金の合計でも、転売したもうけでもありません。

Fは、帰宅の確認を開くだけです。Zで帰宅を決め、Xで取り消します。合計が12以上ならWON(勝ち)です。足りなくても、BELOW(目標に届かない)として終われます。破産や罰にはしません。買い戻す・捨てる・転売する・Undo(元に戻す)はありません。この段階は、Zですぐに買う最小の版です。提示された値段を保存する仕組みは、次の段階で入れます。

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

01_wallet.py / 注目する部分
    stock=list(state.stock)
    stock[shop*2+item]=False

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_wallet.py
macOS / Linux
.venv/bin/python 01_wallet.py
画面で見ること数字1でAの茶を選び、Zを押します。CASH9・SPENT1・袋に1個・TOTAL12になります。F→Zと押すとWONです。数字で品を選ぶだけなら、お金や品物は変わりません。
  • 財布と使ったお金の合計が変わらない
  • 1つの品の持ち主は、いつも1か所だけにする
  • 帰宅は、別の確認の操作に分ける
つまずきやすいところ財布だけを更新して、袋に品を入れ忘れると、払ったお金と持っている品が合わなくなります。買えるかどうかの条件を先に調べてから、1つの新しい State としてまとめて返します。
少し変えてみよう

INITIAL_CASH を10から9に変えて、Aの茶を買って帰ってみましょう。CASH8+価値3でTOTAL11になり、目標の12に届かないので BELOW です。確かめたら10に戻します。

01_wallet.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(208行)
01_wallet.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""うそつき市場・教材1/5。有限の架空市場。現実の投資/価格予測ではありません。"""
from dataclasses import dataclass, replace, asdict

STAGE = 1
ITEMS = ("TEA", "JAM")
BASE = ((1,3),(4,3),(3,3))
INITIAL_CASH = 10
GOAL = 12
BAG_LIMIT = 3

@dataclass(frozen=True)
class State:
    cash: int = INITIAL_CASH
    stock: tuple = (True,)*6
    bag: tuple = ()
    spent: int = 0
    ended: bool = False
    outcome: str = ""
    reason: str = "ready"


def initial():
    return State()


def bag_value(state):
    return len(state.bag)*3


def total(state):
    return state.cash+bag_value(state)


def validate(state):
    assert state.cash >= 0 and state.spent >= 0
    assert state.cash+state.spent == INITIAL_CASH
    assert len(state.bag)<=BAG_LIMIT and len(set(state.bag))==len(state.bag)
    assert sum(state.stock)+len(state.bag)==6
    assert all(not state.stock[shop*2+item] for shop,item in state.bag)


def step(state, action):
    if state.ended:
        return state
    if action == ("finish",):
        result="won" if total(state)>=GOAL else "below"
        return replace(state,ended=True,outcome=result,reason=result)
    if not isinstance(action,tuple) or len(action)!=3 or action[0]!="buy" or type(action[1]) is not int or type(action[2]) is not int or not 0<=action[1]<3 or action[2] not in (0,1):
        return replace(state,reason="invalid")
    shop,item=action[1:]
    if not state.stock[shop*2+item]:
        return replace(state,reason="sold")
    price=BASE[shop][item]
    if state.cash<price:
        return replace(state,reason="money")
    if len(state.bag)>=BAG_LIMIT:
        return replace(state,reason="full")
    stock=list(state.stock)
    stock[shop*2+item]=False
    out=replace(state,cash=state.cash-price,spent=state.spent+price,
                stock=tuple(stock),bag=state.bag+((shop,item),),reason="bought")
    validate(out)
    return out


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

    def reset(self):
        self.state=initial()
        self.selected=0
        self.confirm=False
        self.gated=False
        self.message="READY"

    def control(self,pressed,held):
        if "R" in pressed:
            self.reset()
            self.gated=True
            return
        if self.gated:
            if not pressed and not held:
                self.gated=False
            return
        if len(pressed | held)>1:
            self.gated=True
            self.message="RELEASE ALL KEYS"
            return
        if len(pressed)!=1:
            return
        key=next(iter(pressed))
        self.gated=True
        if self.state.ended:
            return
        if self.confirm:
            if key=="X":
                self.confirm=False
                self.message="RETURN CANCELLED"
            elif key=="Z":
                self.state=step(self.state,("finish",))
                self.confirm=False
                self.message=self.state.reason.upper()
            return
        if key=="F":
            self.confirm=True
            self.message="RETURN NOW? Z YES / X CANCEL"
        elif key in ("LEFT","RIGHT"):
            self.selected=(self.selected+(-1 if key=="LEFT" else 1))%6
            self.message="SELECT ONLY"
        elif key in tuple(str(n) for n in range(1,7)):
            self.selected=int(key)-1
            self.message="SELECT ONLY"
        elif key=="Z":
            self.state=step(self.state,("buy",self.selected//2,self.selected%2))
            self.message=self.state.reason.upper()

    def draw(self,p):
        state=self.state
        p.cls(1)
        p.text(8,8,"FICTIONAL MARKET / STAGE "+str(STAGE),10)
        p.text(8,23,"CASH "+str(state.cash)+"  BAG VALUE "+str(bag_value(state))+"  TOTAL "+str(total(state))+" / GOAL "+str(GOAL),7)
        p.text(8,39,"SPENT "+str(state.spent)+"   BAG "+str(len(state.bag))+" / "+str(BAG_LIMIT),11)
        for n in range(6):
            shop,item=divmod(n,2)
            x=8+shop*164;y=62+item*43
            p.rect(x,y,156,37,2 if n==self.selected else 1)
            p.rectb(x,y,156,37,10 if n==self.selected else 5)
            p.text(x+6,y+6,str(n+1)+" "+"ABC"[shop]+" / "+ITEMS[item],7)
            p.text(x+6,y+20,"BASE "+str(BASE[shop][item])+" / "+("STOCK" if state.stock[n] else "SOLD"),6)
        bag="  ".join("ABC"[shop]+":"+ITEMS[item] for shop,item in state.bag) or "EMPTY"
        p.text(8,156,"BAG ORIGINS: "+bag,7)
        p.text(8,173,"PRACTICE: EVERY ITEM VALUE IS 3",11)
        p.text(8,194,"Z BUYS DIRECTLY AT THE FIXED BASE PRICE.",10)
        p.text(8,237,"FIXED PRICES / NO MERCHANT LEARNING YET",6)
        p.text(8,292,self.message,14)
        p.text(8,309,"RESULT: "+(state.outcome.upper() if state.ended else "SHOPPING"),10)
        p.text(8,330,"ARROWS / 1-6 SELECT   Z ACT   X PASS/CANCEL",7)
        p.text(8,346,"F RETURN CONFIRMATION   R RESET   ESC QUIT",7)
        p.text(8,362,"NO UNDO / NO RESELL / NO REAL MONEY",6)

DEMO_KEYS = ('1', 'Z', 'F', 'Z')


def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(508,382,title="Fictional market lesson "+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App()
    keys={"LEFT":p.KEY_LEFT,"RIGHT":p.KEY_RIGHT,"Z":p.KEY_Z,"X":p.KEY_X,"F":p.KEY_F,"R":p.KEY_R}
    keys.update({str(n):getattr(p,"KEY_"+str(n)) for n in range(1,7)})
    limit=max(16,2*len(DEMO_KEYS)+6) if demo else 16
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) 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=__doc__)
    parser.add_argument("--headless-smoke",action="store_true")
    parser.add_argument("--demo-checkpoint",action="store_true")
    parser.add_argument("--screenshot")
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    if args.screenshot and (Path(args.screenshot).suffix.lower()!=".png" or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error("use a new .png file in an existing directory")
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 02 / 05

開いた提示額を固定する

値段の提示と、購入の確定の2段階。

最初のZでは、買う代わりに Quote(店, 品, 価格)、つまり値段の提示を開きます。いったんキーを離してから押す次のZで、その保存した額を払います。Xなら見送ります。Zを押しっぱなしにしても2回の判断とは数えず、一度すべてのキーを離すまで待ちます。

quote は1件だけです。提示中にカーソルを動かしたり数字を押したりしても、別の品には切り替わりません。画面にある品を買うか見送るかを、先に決めます。提示中にFを押しても、勝手に帰ったり買ったりはしません。まずXで提示を閉じる、教材用の操作です。モデル(ルールの部分)の finish は提示中でも帰宅できますが、UI(画面と操作の部分)では、はっきりした確認を通します。

買うときは、今見えている Quote.price の値段を使います。ここではまだ、どの店も値段は固定です。でも、あとで店主の値段が変わるようになったとき、「今回の提示に、新しい値段をさかのぼって当てはめない」ための区切りになります。

お金が足りないときや袋がいっぱいのときは、買うのを断り、提示は開いたまま残します。変わるのは、画面に出す理由の reason だけです。財布・使ったお金・在庫・袋は変わりません。予告を何度読んでも買ったことにはならず、Xを押せばお金を払わずに見送れます。

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

02_quote.py / 注目する部分
        q=state.quote
        if not state.stock[q.shop*2+q.item]:

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_quote.py
macOS / Linux
.venv/bin/python 02_quote.py
画面で見ることAの茶でZを1回押すと、LOCKED QUOTE PRICE1、CASH10、袋0になります。キーを全部離してからもう一度Zを押すと、CASH9、袋1になります。最初のZを押し続けても買いません。
  • 提示しただけでは買わない
  • 別にもう一度押して、保存した額を払う
  • 断ったときは、持ち物と財布を変えない
つまずきやすいところ買うときに、表示した額とは別に、選んでいる品の BASE を読み直してはいけません。買う品も値段も、保存した Quote から取ります。
少し変えてみよう

BAG_LIMIT を3から1に変えてみましょう。Aの茶を買い、Bの茶を提示してZを押します。お金は足りていても、FULL(袋がいっぱい)で断られ、CASH9・袋1のままです。Xで提示を閉じます。確かめたら3に戻します。

02_quote.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(257行)
02_quote.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""うそつき市場・教材2/5。有限の架空市場。現実の投資/価格予測ではありません。"""
from dataclasses import dataclass, replace, asdict

STAGE = 2
ITEMS = ("TEA", "JAM")
BASE = ((1,3),(4,3),(3,3))
INITIAL_CASH = 10
GOAL = 12
BAG_LIMIT = 3

@dataclass(frozen=True)
class Quote:
    shop: int
    item: int
    price: int

@dataclass(frozen=True)
class State:
    cash: int = INITIAL_CASH
    stock: tuple = (True,)*6
    bag: tuple = ()
    quote: Quote | None = None
    spent: int = 0
    ended: bool = False
    outcome: str = ""
    reason: str = "ready"


def initial():
    return State()


def bag_value(state):
    return len(state.bag)*3


def total(state):
    return state.cash+bag_value(state)


def preview(state):
    if state.quote is None:
        return None
    return {"price":state.quote.price,"new_item_value":3,"existing_gain":0,
            "final_total":total(state)-state.quote.price+3}


def validate(state):
    assert state.cash >= 0 and state.spent >= 0
    assert state.cash+state.spent == INITIAL_CASH
    assert len(state.bag)<=BAG_LIMIT and len(set(state.bag))==len(state.bag)
    assert sum(state.stock)+len(state.bag)==6
    assert all(not state.stock[shop*2+item] for shop,item in state.bag)


def step(state, action):
    if state.ended:
        return state
    if not isinstance(action,tuple) or not action:
        return replace(state,reason="invalid")
    kind=action[0]
    if kind=="quote":
        if state.quote is not None:
            return replace(state,reason="already_quote")
        if len(action)!=3 or type(action[1]) is not int or type(action[2]) is not int or not 0<=action[1]<3 or action[2] not in (0,1):
            return replace(state,reason="invalid")
        shop,item=action[1:]
        if not state.stock[shop*2+item]:
            return replace(state,reason="sold")
        value=BASE[shop][item]
        return replace(state,quote=Quote(shop,item,value),reason="quote")
    if kind=="pass" and len(action)==1:
        if state.quote is None:
            return replace(state,reason="no_quote")
        return replace(state,quote=None,reason="passed")
    if kind=="buy" and len(action)==1:
        if state.quote is None:
            return replace(state,reason="no_quote")
        q=state.quote
        if not state.stock[q.shop*2+q.item]:
            return replace(state,reason="sold")
        if state.cash<q.price:
            return replace(state,reason="money")
        if len(state.bag)>=BAG_LIMIT:
            return replace(state,reason="full")
        stock=list(state.stock)
        stock[q.shop*2+q.item]=False
        out=replace(state,cash=state.cash-q.price,spent=state.spent+q.price,stock=tuple(stock),
                    bag=state.bag+((q.shop,q.item),),quote=None,reason="bought")
        validate(out)
        return out
    if kind=="finish" and len(action)==1:
        result="won" if total(state)>=GOAL else "below"
        return replace(state,quote=None,ended=True,outcome=result,reason=result)
    return replace(state,reason="invalid")


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

    def reset(self):
        self.state=initial()
        self.selected=0
        self.confirm=False
        self.gated=False
        self.message="READY"

    def control(self,pressed,held):
        if "R" in pressed:
            self.reset()
            self.gated=True
            return
        if self.gated:
            if not pressed and not held:
                self.gated=False
            return
        if len(pressed | held)>1:
            self.gated=True
            self.message="RELEASE ALL KEYS"
            return
        if len(pressed)!=1:
            return
        key=next(iter(pressed))
        self.gated=True
        if self.state.ended:
            return
        if self.confirm:
            if key=="X":
                self.confirm=False
                self.message="RETURN CANCELLED"
            elif key=="Z":
                self.state=step(self.state,("finish",))
                self.confirm=False
                self.message=self.state.reason.upper()
            return
        if self.state.quote is not None:
            if key in ("Z","X"):
                self.state=step(self.state,("buy" if key=="Z" else "pass",))
                self.message=self.state.reason.upper()
            else:
                self.message="QUOTE OPEN: Z BUY / X PASS"
            return
        if key=="F":
            self.confirm=True
            self.message="RETURN NOW? Z YES / X CANCEL"
        elif key in ("LEFT","RIGHT"):
            self.selected=(self.selected+(-1 if key=="LEFT" else 1))%6
            self.message="SELECT ONLY"
        elif key in tuple(str(n) for n in range(1,7)):
            self.selected=int(key)-1
            self.message="SELECT ONLY"
        elif key=="Z":
            self.state=step(self.state,("quote",self.selected//2,self.selected%2))
            self.message=self.state.reason.upper()

    def draw(self,p):
        state=self.state
        p.cls(1)
        p.text(8,8,"FICTIONAL MARKET / STAGE "+str(STAGE),10)
        p.text(8,23,"CASH "+str(state.cash)+"  BAG VALUE "+str(bag_value(state))+"  TOTAL "+str(total(state))+" / GOAL "+str(GOAL),7)
        p.text(8,39,"SPENT "+str(state.spent)+"   BAG "+str(len(state.bag))+" / "+str(BAG_LIMIT),11)
        for n in range(6):
            shop,item=divmod(n,2)
            x=8+shop*164;y=62+item*43
            p.rect(x,y,156,37,2 if n==self.selected else 1)
            p.rectb(x,y,156,37,10 if n==self.selected else 5)
            p.text(x+6,y+6,str(n+1)+" "+"ABC"[shop]+" / "+ITEMS[item],7)
            p.text(x+6,y+20,"BASE "+str(BASE[shop][item])+" / "+("STOCK" if state.stock[n] else "SOLD"),6)
        bag="  ".join("ABC"[shop]+":"+ITEMS[item] for shop,item in state.bag) or "EMPTY"
        p.text(8,156,"BAG ORIGINS: "+bag,7)
        p.text(8,173,"PRACTICE: EVERY ITEM VALUE IS 3",11)
        q=state.quote
        if q:
            p.text(8,194,"LOCKED QUOTE: "+"ABC"[q.shop]+" "+ITEMS[q.item]+" / PRICE "+str(q.price),10)
            blocked="MONEY" if state.cash<q.price else "FULL" if len(state.bag)>=BAG_LIMIT else ""
            if blocked:
                p.text(8,210,"BUY REFUSED: "+blocked+" / X: PASS",8)
            else:
                forecast=preview(state)
                p.text(8,210,"IF BOUGHT: NEW "+str(forecast['new_item_value'])+" / OLD GAIN "+str(forecast['existing_gain'])+" / TOTAL "+str(forecast['final_total']),11)
        else:
            p.text(8,194,"Z OPENS QUOTE. NEXT Z PAYS LOCKED PRICE.",10)
        p.text(8,237,"FIXED PRICES / NO MERCHANT LEARNING YET",6)
        p.text(8,292,self.message,14)
        p.text(8,309,"RESULT: "+(state.outcome.upper() if state.ended else "SHOPPING"),10)
        p.text(8,330,"ARROWS / 1-6 SELECT   Z ACT   X PASS/CANCEL",7)
        p.text(8,346,"F RETURN CONFIRMATION   R RESET   ESC QUIT",7)
        p.text(8,362,"NO UNDO / NO RESELL / NO REAL MONEY",6)

DEMO_KEYS = ('1', 'Z', 'Z', 'F', 'Z')


def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(508,382,title="Fictional market lesson "+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App()
    keys={"LEFT":p.KEY_LEFT,"RIGHT":p.KEY_RIGHT,"Z":p.KEY_Z,"X":p.KEY_X,"F":p.KEY_F,"R":p.KEY_R}
    keys.update({str(n):getattr(p,"KEY_"+str(n)) for n in range(1,7)})
    limit=max(16,2*len(DEMO_KEYS)+6) if demo else 16
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) 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=__doc__)
    parser.add_argument("--headless-smoke",action="store_true")
    parser.add_argument("--demo-checkpoint",action="store_true")
    parser.add_argument("--screenshot")
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    if args.screenshot and (Path(args.screenshot).suffix.lower()!=".png" or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error("use a new .png file in an existing directory")
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 03 / 05

店ごとの記憶を一度だけ変える

相手(店主)に渡す情報をしぼる。

元のゲームのBにある、3種類の店主のルールを加えます。fixed は、最初の値段(初値)のままです。rush は、一度見送られると初値より1安くなります(最低1)。bold は、一度買われると、残りの品が初値の2倍になります。何度見送っても、毎回1ずつ下がるわけではありません。もう変化したかどうかを、真偽(True・False)で覚えておきます。

Memory(店主の記憶)の bought(買われた)と passed(見送られた)は、「古い値 OR 今回の出来事」で更新します。同じ店の茶を買うと、その店のジャムにも効きます。Observation(店主が見る情報)に渡すのは、自分の店で実際に買われたか見送られたかと、品の種類だけです。ほかの店、財布、袋、カーソルは渡しません。プレイヤー個人の過去の遊びを保存する仕組みでもありません。

Aの茶は最初1です。提示された値段1を払ったあとで、bought が True になります。その次のジャムの提示は、初値3の2倍で6です。買い物が成立する前に値上げして、今回の茶に2を請求すると、「提示した値段は変えない」という約束を壊してしまいます。買えなかったときは、記憶も更新しません。

この段階は学習用なので、どの品も価値3、目標12のままです。店主の本当のタイプは、画面に直接は出しません。中の設定を説明しながら変化を試す練習と、初めて見て推理する遊びは別のものです。見た出来事の記録(履歴)から、タイプの候補を表示するのは05です。

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

03_memory.py / 注目する部分
    return Memory(memory.bought or obs.event == 'buy', memory.passed or obs.event == 'pass')

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_memory.py
macOS / Linux
.venv/bin/python 03_memory.py
画面で見ることAの茶を提示→購入すると、CASH9になります。そのあとAのジャムを提示すると、PRICE6です。Cの茶を一度見送ってからもう一度提示するとPRICE2で、さらに見送っても2のままです。
  • 記憶による値段の変化は一度だけ
  • 自分の店で実際に起きた出来事だけを渡す
  • 今出ている提示の値段は、あとから上げない
つまずきやすいところ値段は、今の値段から毎回値引きしたり2倍にしたりして求めるのではありません。元の BASE と、一度だけ変わる Memory から求めます。また、袋がいっぱいなどで断られたときを、買えたこととして店主に覚えさせてはいけません。
少し変えてみよう

ACTUAL_KINDS のAだけを、bold から fixed に変えてみましょう。Aの茶を買ったあとでAのジャムを提示しても、3のままです。確かめたら bold に戻します。元のゲームと結果を比べるテストは、もとの設定のままであることを前提にしています。

03_memory.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(303行)
03_memory.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""うそつき市場・教材3/5。有限の架空市場。現実の投資/価格予測ではありません。"""
from dataclasses import dataclass, replace, asdict

STAGE = 3
ITEMS = ("TEA", "JAM")
BASE = ((1,3),(4,3),(3,3))
INITIAL_CASH = 10
GOAL = 12
BAG_LIMIT = 3
KINDS = ("fixed", "rush", "bold")
ACTUAL_KINDS = ("bold", "fixed", "rush")

@dataclass(frozen=True)
class Memory:
    bought: bool = False
    passed: bool = False

@dataclass(frozen=True)
class Observation:
    event: str
    item: int

@dataclass(frozen=True)
class Quote:
    shop: int
    item: int
    price: int

@dataclass(frozen=True)
class Evidence:
    event: str
    item: int
    price: int = 0

@dataclass(frozen=True)
class State:
    cash: int = INITIAL_CASH
    stock: tuple = (True,)*6
    bag: tuple = ()
    memories: tuple = (Memory(),)*3
    history: tuple = ((),(),())
    quote: Quote | None = None
    spent: int = 0
    ended: bool = False
    outcome: str = ""
    reason: str = "ready"


def initial():
    return State()

def price_policy(kind, base, memory):
    if kind == 'rush' and memory.passed:
        return max(1, base - 1)
    if kind == 'bold' and memory.bought:
        return base * 2
    return base

def observe(event, item):
    return Observation(event, item)

def remember(memory, obs):
    return Memory(memory.bought or obs.event == 'buy', memory.passed or obs.event == 'pass')

def add_evidence(s, shop, event, item, price=0):
    h = list(s.history)
    h[shop] = h[shop] + (Evidence(event, item, price),)
    return replace(s, history=tuple(h))


def bag_value(state):
    return len(state.bag)*3


def total(state):
    return state.cash+bag_value(state)


def preview(state):
    if state.quote is None:
        return None
    return {"price":state.quote.price,"new_item_value":3,"existing_gain":0,
            "final_total":total(state)-state.quote.price+3}


def validate(state):
    assert state.cash >= 0 and state.spent >= 0
    assert state.cash+state.spent == INITIAL_CASH
    assert len(state.bag)<=BAG_LIMIT and len(set(state.bag))==len(state.bag)
    assert sum(state.stock)+len(state.bag)==6
    assert all(not state.stock[shop*2+item] for shop,item in state.bag)


def step(state, action):
    if state.ended:
        return state
    if not isinstance(action,tuple) or not action:
        return replace(state,reason="invalid")
    kind=action[0]
    if kind=="quote":
        if state.quote is not None:
            return replace(state,reason="already_quote")
        if len(action)!=3 or type(action[1]) is not int or type(action[2]) is not int or not 0<=action[1]<3 or action[2] not in (0,1):
            return replace(state,reason="invalid")
        shop,item=action[1:]
        if not state.stock[shop*2+item]:
            return replace(state,reason="sold")
        value=price_policy(ACTUAL_KINDS[shop],BASE[shop][item],state.memories[shop])
        state=add_evidence(state,shop,"quote",item,value)
        return replace(state,quote=Quote(shop,item,value),reason="quote")
    if kind=="pass" and len(action)==1:
        if state.quote is None:
            return replace(state,reason="no_quote")
        q=state.quote
        memories=list(state.memories)
        memories[q.shop]=remember(memories[q.shop],observe("pass",q.item))
        state=add_evidence(state,q.shop,"pass",q.item)
        return replace(state,quote=None,memories=tuple(memories),reason="passed")
    if kind=="buy" and len(action)==1:
        if state.quote is None:
            return replace(state,reason="no_quote")
        q=state.quote
        if not state.stock[q.shop*2+q.item]:
            return replace(state,reason="sold")
        if state.cash<q.price:
            return replace(state,reason="money")
        if len(state.bag)>=BAG_LIMIT:
            return replace(state,reason="full")
        stock=list(state.stock)
        stock[q.shop*2+q.item]=False
        memories=list(state.memories)
        memories[q.shop]=remember(memories[q.shop],observe("buy",q.item))
        state=add_evidence(state,q.shop,"buy",q.item,q.price)
        out=replace(state,cash=state.cash-q.price,spent=state.spent+q.price,stock=tuple(stock),
                    bag=state.bag+((q.shop,q.item),),quote=None,reason="bought",memories=tuple(memories))
        validate(out)
        return out
    if kind=="finish" and len(action)==1:
        result="won" if total(state)>=GOAL else "below"
        return replace(state,quote=None,ended=True,outcome=result,reason=result)
    return replace(state,reason="invalid")


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

    def reset(self):
        self.state=initial()
        self.selected=0
        self.confirm=False
        self.gated=False
        self.message="READY"

    def control(self,pressed,held):
        if "R" in pressed:
            self.reset()
            self.gated=True
            return
        if self.gated:
            if not pressed and not held:
                self.gated=False
            return
        if len(pressed | held)>1:
            self.gated=True
            self.message="RELEASE ALL KEYS"
            return
        if len(pressed)!=1:
            return
        key=next(iter(pressed))
        self.gated=True
        if self.state.ended:
            return
        if self.confirm:
            if key=="X":
                self.confirm=False
                self.message="RETURN CANCELLED"
            elif key=="Z":
                self.state=step(self.state,("finish",))
                self.confirm=False
                self.message=self.state.reason.upper()
            return
        if self.state.quote is not None:
            if key in ("Z","X"):
                self.state=step(self.state,("buy" if key=="Z" else "pass",))
                self.message=self.state.reason.upper()
            else:
                self.message="QUOTE OPEN: Z BUY / X PASS"
            return
        if key=="F":
            self.confirm=True
            self.message="RETURN NOW? Z YES / X CANCEL"
        elif key in ("LEFT","RIGHT"):
            self.selected=(self.selected+(-1 if key=="LEFT" else 1))%6
            self.message="SELECT ONLY"
        elif key in tuple(str(n) for n in range(1,7)):
            self.selected=int(key)-1
            self.message="SELECT ONLY"
        elif key=="Z":
            self.state=step(self.state,("quote",self.selected//2,self.selected%2))
            self.message=self.state.reason.upper()

    def draw(self,p):
        state=self.state
        p.cls(1)
        p.text(8,8,"FICTIONAL MARKET / STAGE "+str(STAGE),10)
        p.text(8,23,"CASH "+str(state.cash)+"  BAG VALUE "+str(bag_value(state))+"  TOTAL "+str(total(state))+" / GOAL "+str(GOAL),7)
        p.text(8,39,"SPENT "+str(state.spent)+"   BAG "+str(len(state.bag))+" / "+str(BAG_LIMIT),11)
        for n in range(6):
            shop,item=divmod(n,2)
            x=8+shop*164;y=62+item*43
            p.rect(x,y,156,37,2 if n==self.selected else 1)
            p.rectb(x,y,156,37,10 if n==self.selected else 5)
            p.text(x+6,y+6,str(n+1)+" "+"ABC"[shop]+" / "+ITEMS[item],7)
            p.text(x+6,y+20,"BASE "+str(BASE[shop][item])+" / "+("STOCK" if state.stock[n] else "SOLD"),6)
        bag="  ".join("ABC"[shop]+":"+ITEMS[item] for shop,item in state.bag) or "EMPTY"
        p.text(8,156,"BAG ORIGINS: "+bag,7)
        p.text(8,173,"PRACTICE: EVERY ITEM VALUE IS 3",11)
        q=state.quote
        if q:
            p.text(8,194,"LOCKED QUOTE: "+"ABC"[q.shop]+" "+ITEMS[q.item]+" / PRICE "+str(q.price),10)
            blocked="MONEY" if state.cash<q.price else "FULL" if len(state.bag)>=BAG_LIMIT else ""
            if blocked:
                p.text(8,210,"BUY REFUSED: "+blocked+" / X: PASS",8)
            else:
                forecast=preview(state)
                p.text(8,210,"IF BOUGHT: NEW "+str(forecast['new_item_value'])+" / OLD GAIN "+str(forecast['existing_gain'])+" / TOTAL "+str(forecast['final_total']),11)
        else:
            p.text(8,194,"Z OPENS QUOTE. NEXT Z PAYS LOCKED PRICE.",10)
        p.text(8,237,"ONE-TIME MERCHANT MEMORY / NOT PERSONAL DATA",6)
        p.text(8,292,self.message,14)
        p.text(8,309,"RESULT: "+(state.outcome.upper() if state.ended else "SHOPPING"),10)
        p.text(8,330,"ARROWS / 1-6 SELECT   Z ACT   X PASS/CANCEL",7)
        p.text(8,346,"F RETURN CONFIRMATION   R RESET   ESC QUIT",7)
        p.text(8,362,"NO UNDO / NO RESELL / NO REAL MONEY",6)

DEMO_KEYS = ('1', 'Z', 'Z', '2', 'Z')


def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(508,382,title="Fictional market lesson "+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App()
    keys={"LEFT":p.KEY_LEFT,"RIGHT":p.KEY_RIGHT,"Z":p.KEY_Z,"X":p.KEY_X,"F":p.KEY_F,"R":p.KEY_R}
    keys.update({str(n):getattr(p,"KEY_"+str(n)) for n in range(1,7)})
    limit=max(16,2*len(DEMO_KEYS)+6) if demo else 16
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) 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=__doc__)
    parser.add_argument("--headless-smoke",action="store_true")
    parser.add_argument("--demo-checkpoint",action="store_true")
    parser.add_argument("--screenshot")
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    if args.screenshot and (Path(args.screenshot).suffix.lower()!=".png" or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error("use a new .png file in an existing directory")
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 04 / 05

隣の在庫で手持ちの価値も変える

新しく買う品の価値と、もう持っている品の価値の増え方を分ける。

元のゲームのBの、ふつうの目標15と、となりのボーナスを加えます。店は A—B—C と一直線に並んでいます。品の価値は、3に、同じ品が売り切れたとなりの店1軒につき2を足した値です。AとCは、となり同士ではありません。茶の売り切れはとなりの店の茶に効き、ジャムには効きません。

袋に入れた品も、となりの店で売り切れると価値が変わります。そのため、value(価値)を、買った瞬間の数字として保存しておくことはしません。bag に残した出身の店・品の種類と、今の stock から計算し直します。だれが持っているかと、今いくらの価値と見るかを、分けて考える作り方です。

Aの茶を1で買うと、合計は12です。そのあとBの茶を4で買うと、Aの茶が売り切れているので、新しいBの茶の価値は5です。持っていたAの茶も、Bの茶が売り切れたので2増えます。12-4+5+2=15です。preview(予告)は、新しい品の価値と、もう持っている品の増えた分を別々に表示します。買える場合だけ、IF BOUGHT(買ったら)を表示します。

買う順番も結果に関わります。Aのジャム3→Aの茶2→Bのジャム3の順なら、残りのお金2・袋の価値13で、合計15です。Aの茶1→Aのジャム6→Bのジャム3の順では、同じ3つの品でも残りのお金は0で、合計13です。安い品を試しに買うのが、いつも悪いわけではありません。元のゲームには、途中で別の品に切り替えて成功する手順もあります。

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

04_neighbors.py / 注目する部分
    existing_gain = sum((item_value(tuple(stock), sh, it, neighbor_bonus) - item_value(s.stock, sh, it, neighbor_bonus) for sh, it in s.bag))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_neighbors.py
macOS / Linux
.venv/bin/python 04_neighbors.py
画面で見ることAの茶を買い、Bの茶を提示します。IF BOUGHT は NEW5 / OLD GAIN2 / TOTAL15 と表示されます。買って帰ると、2つの品だけでWONです。もう持っていたAの茶も、価値5になっています。
  • AとCは、となり同士にしない
  • 袋に、買った店(出身の店)を残す
  • もう持っている品の増える分も予告する
つまずきやすいところ新しく買う品の価値だけを足すと、もう持っている品が2増える分が抜けてしまいます。逆に、表示した増えた分を State に足して保存すると、画面を描くたびに価値が増えてしまうおそれがあります。
少し変えてみよう

NEIGHBOR_GAIN を2から1に変えて、同じようにAの茶→Bの茶と買ってみましょう。新しいBの茶は4、Aの茶は1増えるので、合計13です。確かめたら2に戻します。

04_neighbors.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(310行)
04_neighbors.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""うそつき市場・教材4/5。有限の架空市場。現実の投資/価格予測ではありません。"""
from dataclasses import dataclass, replace, asdict

STAGE = 4
ITEMS = ("TEA", "JAM")
BASE = ((1,3),(4,3),(3,3))
INITIAL_CASH = 10
GOAL = 15
BAG_LIMIT = 3
KINDS = ("fixed", "rush", "bold")
ACTUAL_KINDS = ("bold", "fixed", "rush")

@dataclass(frozen=True)
class Memory:
    bought: bool = False
    passed: bool = False

@dataclass(frozen=True)
class Observation:
    event: str
    item: int

@dataclass(frozen=True)
class Quote:
    shop: int
    item: int
    price: int

@dataclass(frozen=True)
class Evidence:
    event: str
    item: int
    price: int = 0

@dataclass(frozen=True)
class State:
    cash: int = INITIAL_CASH
    stock: tuple = (True,)*6
    bag: tuple = ()
    memories: tuple = (Memory(),)*3
    history: tuple = ((),(),())
    quote: Quote | None = None
    spent: int = 0
    ended: bool = False
    outcome: str = ""
    reason: str = "ready"


def initial():
    return State()

def price_policy(kind, base, memory):
    if kind == 'rush' and memory.passed:
        return max(1, base - 1)
    if kind == 'bold' and memory.bought:
        return base * 2
    return base

def observe(event, item):
    return Observation(event, item)

def remember(memory, obs):
    return Memory(memory.bought or obs.event == 'buy', memory.passed or obs.event == 'pass')

def add_evidence(s, shop, event, item, price=0):
    h = list(s.history)
    h[shop] = h[shop] + (Evidence(event, item, price),)
    return replace(s, history=tuple(h))

NEIGHBOR_GAIN = 2

def item_value(stock, shop, item, neighbor_bonus=True):
    neighbors = tuple((s for s in (shop - 1, shop + 1) if 0 <= s < 3))
    return 3 + (NEIGHBOR_GAIN * sum((not stock[s * 2 + item] for s in neighbors)) if neighbor_bonus else 0)

def bag_value(s, neighbor_bonus=True):
    return sum((item_value(s.stock, shop, item, neighbor_bonus) for shop, item in s.bag))

def total(s, neighbor_bonus=True):
    return s.cash + bag_value(s, neighbor_bonus)

def preview(s, neighbor_bonus=True):
    if s.quote is None:
        return None
    q = s.quote
    stock = list(s.stock)
    stock[q.shop * 2 + q.item] = False
    new_item = item_value(tuple(stock), q.shop, q.item, neighbor_bonus)
    existing_gain = sum((item_value(tuple(stock), sh, it, neighbor_bonus) - item_value(s.stock, sh, it, neighbor_bonus) for sh, it in s.bag))
    return {'price': q.price, 'new_item_value': new_item, 'existing_gain': existing_gain, 'final_total': total(s, neighbor_bonus) - q.price + new_item + existing_gain}


def validate(state):
    assert state.cash >= 0 and state.spent >= 0
    assert state.cash+state.spent == INITIAL_CASH
    assert len(state.bag)<=BAG_LIMIT and len(set(state.bag))==len(state.bag)
    assert sum(state.stock)+len(state.bag)==6
    assert all(not state.stock[shop*2+item] for shop,item in state.bag)


def step(state, action):
    if state.ended:
        return state
    if not isinstance(action,tuple) or not action:
        return replace(state,reason="invalid")
    kind=action[0]
    if kind=="quote":
        if state.quote is not None:
            return replace(state,reason="already_quote")
        if len(action)!=3 or type(action[1]) is not int or type(action[2]) is not int or not 0<=action[1]<3 or action[2] not in (0,1):
            return replace(state,reason="invalid")
        shop,item=action[1:]
        if not state.stock[shop*2+item]:
            return replace(state,reason="sold")
        value=price_policy(ACTUAL_KINDS[shop],BASE[shop][item],state.memories[shop])
        state=add_evidence(state,shop,"quote",item,value)
        return replace(state,quote=Quote(shop,item,value),reason="quote")
    if kind=="pass" and len(action)==1:
        if state.quote is None:
            return replace(state,reason="no_quote")
        q=state.quote
        memories=list(state.memories)
        memories[q.shop]=remember(memories[q.shop],observe("pass",q.item))
        state=add_evidence(state,q.shop,"pass",q.item)
        return replace(state,quote=None,memories=tuple(memories),reason="passed")
    if kind=="buy" and len(action)==1:
        if state.quote is None:
            return replace(state,reason="no_quote")
        q=state.quote
        if not state.stock[q.shop*2+q.item]:
            return replace(state,reason="sold")
        if state.cash<q.price:
            return replace(state,reason="money")
        if len(state.bag)>=BAG_LIMIT:
            return replace(state,reason="full")
        stock=list(state.stock)
        stock[q.shop*2+q.item]=False
        memories=list(state.memories)
        memories[q.shop]=remember(memories[q.shop],observe("buy",q.item))
        state=add_evidence(state,q.shop,"buy",q.item,q.price)
        out=replace(state,cash=state.cash-q.price,spent=state.spent+q.price,stock=tuple(stock),
                    bag=state.bag+((q.shop,q.item),),quote=None,reason="bought",memories=tuple(memories))
        validate(out)
        return out
    if kind=="finish" and len(action)==1:
        result="won" if total(state)>=GOAL else "below"
        return replace(state,quote=None,ended=True,outcome=result,reason=result)
    return replace(state,reason="invalid")


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

    def reset(self):
        self.state=initial()
        self.selected=0
        self.confirm=False
        self.gated=False
        self.message="READY"

    def control(self,pressed,held):
        if "R" in pressed:
            self.reset()
            self.gated=True
            return
        if self.gated:
            if not pressed and not held:
                self.gated=False
            return
        if len(pressed | held)>1:
            self.gated=True
            self.message="RELEASE ALL KEYS"
            return
        if len(pressed)!=1:
            return
        key=next(iter(pressed))
        self.gated=True
        if self.state.ended:
            return
        if self.confirm:
            if key=="X":
                self.confirm=False
                self.message="RETURN CANCELLED"
            elif key=="Z":
                self.state=step(self.state,("finish",))
                self.confirm=False
                self.message=self.state.reason.upper()
            return
        if self.state.quote is not None:
            if key in ("Z","X"):
                self.state=step(self.state,("buy" if key=="Z" else "pass",))
                self.message=self.state.reason.upper()
            else:
                self.message="QUOTE OPEN: Z BUY / X PASS"
            return
        if key=="F":
            self.confirm=True
            self.message="RETURN NOW? Z YES / X CANCEL"
        elif key in ("LEFT","RIGHT"):
            self.selected=(self.selected+(-1 if key=="LEFT" else 1))%6
            self.message="SELECT ONLY"
        elif key in tuple(str(n) for n in range(1,7)):
            self.selected=int(key)-1
            self.message="SELECT ONLY"
        elif key=="Z":
            self.state=step(self.state,("quote",self.selected//2,self.selected%2))
            self.message=self.state.reason.upper()

    def draw(self,p):
        state=self.state
        p.cls(1)
        p.text(8,8,"FICTIONAL MARKET / STAGE "+str(STAGE),10)
        p.text(8,23,"CASH "+str(state.cash)+"  BAG VALUE "+str(bag_value(state))+"  TOTAL "+str(total(state))+" / GOAL "+str(GOAL),7)
        p.text(8,39,"SPENT "+str(state.spent)+"   BAG "+str(len(state.bag))+" / "+str(BAG_LIMIT),11)
        for n in range(6):
            shop,item=divmod(n,2)
            x=8+shop*164;y=62+item*43
            p.rect(x,y,156,37,2 if n==self.selected else 1)
            p.rectb(x,y,156,37,10 if n==self.selected else 5)
            p.text(x+6,y+6,str(n+1)+" "+"ABC"[shop]+" / "+ITEMS[item],7)
            p.text(x+6,y+20,"BASE "+str(BASE[shop][item])+" / "+("STOCK" if state.stock[n] else "SOLD"),6)
        bag="  ".join("ABC"[shop]+":"+ITEMS[item] for shop,item in state.bag) or "EMPTY"
        p.text(8,156,"BAG ORIGINS: "+bag,7)
        p.text(8,173,"VALUE = 3 + 2 PER EMPTY SAME-ITEM NEIGHBOR",11)
        q=state.quote
        if q:
            p.text(8,194,"LOCKED QUOTE: "+"ABC"[q.shop]+" "+ITEMS[q.item]+" / PRICE "+str(q.price),10)
            blocked="MONEY" if state.cash<q.price else "FULL" if len(state.bag)>=BAG_LIMIT else ""
            if blocked:
                p.text(8,210,"BUY REFUSED: "+blocked+" / X: PASS",8)
            else:
                forecast=preview(state)
                p.text(8,210,"IF BOUGHT: NEW "+str(forecast['new_item_value'])+" / OLD GAIN "+str(forecast['existing_gain'])+" / TOTAL "+str(forecast['final_total']),11)
        else:
            p.text(8,194,"Z OPENS QUOTE. NEXT Z PAYS LOCKED PRICE.",10)
        p.text(8,237,"ONE-TIME MERCHANT MEMORY / NOT PERSONAL DATA",6)
        p.text(8,292,self.message,14)
        p.text(8,309,"RESULT: "+(state.outcome.upper() if state.ended else "SHOPPING"),10)
        p.text(8,330,"ARROWS / 1-6 SELECT   Z ACT   X PASS/CANCEL",7)
        p.text(8,346,"F RETURN CONFIRMATION   R RESET   ESC QUIT",7)
        p.text(8,362,"NO UNDO / NO RESELL / NO REAL MONEY",6)

DEMO_KEYS = ('2', 'Z', 'Z', '1', 'Z', 'Z', '4', 'Z', 'Z', 'F', 'Z')


def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(508,382,title="Fictional market lesson "+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App()
    keys={"LEFT":p.KEY_LEFT,"RIGHT":p.KEY_RIGHT,"Z":p.KEY_Z,"X":p.KEY_X,"F":p.KEY_F,"R":p.KEY_R}
    keys.update({str(n):getattr(p,"KEY_"+str(n)) for n in range(1,7)})
    limit=max(16,2*len(DEMO_KEYS)+6) if demo else 16
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) 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=__doc__)
    parser.add_argument("--headless-smoke",action="store_true")
    parser.add_argument("--demo-checkpoint",action="store_true")
    parser.add_argument("--screenshot")
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    if args.screenshot and (Path(args.screenshot).suffix.lower()!=".png" or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error("use a new .png file in an existing directory")
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
STEP 05 / 05

見た価格だけから候補を残す

見た事実と、内部にある正解を分ける。

画面の POSSIBLE(ありうる候補)に、これまでの履歴と矛盾しない店主のタイプを表示します。candidates に渡すのは、実際に見た提示・成立した購入・見送りの履歴と、だれでも知っている初値だけです。ACTUAL_KINDS の正解は渡しません。

候補のタイプ1つごとに、空の Memory から、履歴を最初から順に再生します。quote(提示)なら、その時点のルールで同じ値段になるかを調べます。buy(購入)や pass(見送り)なら、そのあとの Memory を更新します。あとから覚えたことを、過去の最初の値段に当てはめないように、順番を守ります。

最初に提示される値段は、3つのタイプとも初値です。そのため、見ただけでは候補が全部残ります。Aの茶を買っただけでも、まだ同じです。次に、Aのジャムを実際に提示させて6だと分かったとき、初めて bold だけになります。Bの茶を買い、次のBのジャムが3でも、fixed と rush の2つが候補に残ります。買っても値段が変わらなかった、というだけでは1つに決まりません。

この表示を足しても、財布や、ゲームの成功の条件は変わりません。候補を1つにしぼることは、勝ちの条件ではありません。勝ち負けは、帰宅したときの「残りのお金+袋の価値」で決めます。元のゲームのBにある1696種類のお金と品の状態(経済状態)について、代表的な履歴を選び、32792の状態の移り変わり(遷移)を元のゲームと照らし合わせています。何度でもくり返せる提示と見送りの、すべての履歴を並べて確かめた証明ではありません。

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

05_evidence.py / 注目する部分
            if e.event == 'quote' and price_policy(kind, base[e.item], m) != e.price:

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_evidence.py
macOS / Linux
.venv/bin/python 05_evidence.py
画面で見ることAの茶を買い、Aのジャムを提示すると、A POSSIBLE: bold と表示されます。Bの茶を買ってBのジャムを提示した場合は、fixed / rush です。この候補は、内部の正解の名前をそのまま表示したものではありません。
  • 履歴を古い順に再生する
  • 矛盾しない候補を全部残す
  • 候補の数と、ゲームの成功を分けて考える
つまずきやすいところ「買われた」という1つの事実だけで、bold と決めてはいけません。ほかのタイプの店でも、買うこと自体はできます。そのあとに見た値段と矛盾する候補だけを消します。
少し変えてみよう

ACTUAL_KINDS のAだけを rush に変えてみましょう。Aの茶を買ったあと、Aのジャムが3で提示されると、候補は fixed と rush です。Xで見送り、もう一度提示して2になると、rush だけになります。これはルールを変えて試す練習で、あなたが初見で推理する力を調べるものではありません。確かめたら bold に戻します。

05_evidence.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(328行)
05_evidence.py / 全文
# MIT License
# 
# Copyright (c) 2026 Pyxel 100 contributors
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# 
# The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL.
# See assets/NOTO-LICENSE.txt.

"""うそつき市場・教材5/5。有限の架空市場。現実の投資/価格予測ではありません。"""
from dataclasses import dataclass, replace, asdict

STAGE = 5
ITEMS = ("TEA", "JAM")
BASE = ((1,3),(4,3),(3,3))
INITIAL_CASH = 10
GOAL = 15
BAG_LIMIT = 3
KINDS = ("fixed", "rush", "bold")
ACTUAL_KINDS = ("bold", "fixed", "rush")

@dataclass(frozen=True)
class Memory:
    bought: bool = False
    passed: bool = False

@dataclass(frozen=True)
class Observation:
    event: str
    item: int

@dataclass(frozen=True)
class Quote:
    shop: int
    item: int
    price: int

@dataclass(frozen=True)
class Evidence:
    event: str
    item: int
    price: int = 0

@dataclass(frozen=True)
class State:
    cash: int = INITIAL_CASH
    stock: tuple = (True,)*6
    bag: tuple = ()
    memories: tuple = (Memory(),)*3
    history: tuple = ((),(),())
    quote: Quote | None = None
    spent: int = 0
    ended: bool = False
    outcome: str = ""
    reason: str = "ready"


def initial():
    return State()

def price_policy(kind, base, memory):
    if kind == 'rush' and memory.passed:
        return max(1, base - 1)
    if kind == 'bold' and memory.bought:
        return base * 2
    return base

def observe(event, item):
    return Observation(event, item)

def remember(memory, obs):
    return Memory(memory.bought or obs.event == 'buy', memory.passed or obs.event == 'pass')

def add_evidence(s, shop, event, item, price=0):
    h = list(s.history)
    h[shop] = h[shop] + (Evidence(event, item, price),)
    return replace(s, history=tuple(h))

def candidates(history, base):
    result = []
    for kind in KINDS:
        m = Memory()
        valid = True
        for e in history:
            if e.event == 'quote' and price_policy(kind, base[e.item], m) != e.price:
                valid = False
                break
            if e.event in ('buy', 'pass'):
                m = remember(m, observe(e.event, e.item))
        if valid:
            result.append(kind)
    return tuple(result)

NEIGHBOR_GAIN = 2

def item_value(stock, shop, item, neighbor_bonus=True):
    neighbors = tuple((s for s in (shop - 1, shop + 1) if 0 <= s < 3))
    return 3 + (NEIGHBOR_GAIN * sum((not stock[s * 2 + item] for s in neighbors)) if neighbor_bonus else 0)

def bag_value(s, neighbor_bonus=True):
    return sum((item_value(s.stock, shop, item, neighbor_bonus) for shop, item in s.bag))

def total(s, neighbor_bonus=True):
    return s.cash + bag_value(s, neighbor_bonus)

def preview(s, neighbor_bonus=True):
    if s.quote is None:
        return None
    q = s.quote
    stock = list(s.stock)
    stock[q.shop * 2 + q.item] = False
    new_item = item_value(tuple(stock), q.shop, q.item, neighbor_bonus)
    existing_gain = sum((item_value(tuple(stock), sh, it, neighbor_bonus) - item_value(s.stock, sh, it, neighbor_bonus) for sh, it in s.bag))
    return {'price': q.price, 'new_item_value': new_item, 'existing_gain': existing_gain, 'final_total': total(s, neighbor_bonus) - q.price + new_item + existing_gain}


def validate(state):
    assert state.cash >= 0 and state.spent >= 0
    assert state.cash+state.spent == INITIAL_CASH
    assert len(state.bag)<=BAG_LIMIT and len(set(state.bag))==len(state.bag)
    assert sum(state.stock)+len(state.bag)==6
    assert all(not state.stock[shop*2+item] for shop,item in state.bag)
    assert all(ACTUAL_KINDS[i] in candidates(state.history[i],BASE[i]) for i in range(3))


def step(state, action):
    if state.ended:
        return state
    if not isinstance(action,tuple) or not action:
        return replace(state,reason="invalid")
    kind=action[0]
    if kind=="quote":
        if state.quote is not None:
            return replace(state,reason="already_quote")
        if len(action)!=3 or type(action[1]) is not int or type(action[2]) is not int or not 0<=action[1]<3 or action[2] not in (0,1):
            return replace(state,reason="invalid")
        shop,item=action[1:]
        if not state.stock[shop*2+item]:
            return replace(state,reason="sold")
        value=price_policy(ACTUAL_KINDS[shop],BASE[shop][item],state.memories[shop])
        state=add_evidence(state,shop,"quote",item,value)
        return replace(state,quote=Quote(shop,item,value),reason="quote")
    if kind=="pass" and len(action)==1:
        if state.quote is None:
            return replace(state,reason="no_quote")
        q=state.quote
        memories=list(state.memories)
        memories[q.shop]=remember(memories[q.shop],observe("pass",q.item))
        state=add_evidence(state,q.shop,"pass",q.item)
        return replace(state,quote=None,memories=tuple(memories),reason="passed")
    if kind=="buy" and len(action)==1:
        if state.quote is None:
            return replace(state,reason="no_quote")
        q=state.quote
        if not state.stock[q.shop*2+q.item]:
            return replace(state,reason="sold")
        if state.cash<q.price:
            return replace(state,reason="money")
        if len(state.bag)>=BAG_LIMIT:
            return replace(state,reason="full")
        stock=list(state.stock)
        stock[q.shop*2+q.item]=False
        memories=list(state.memories)
        memories[q.shop]=remember(memories[q.shop],observe("buy",q.item))
        state=add_evidence(state,q.shop,"buy",q.item,q.price)
        out=replace(state,cash=state.cash-q.price,spent=state.spent+q.price,stock=tuple(stock),
                    bag=state.bag+((q.shop,q.item),),quote=None,reason="bought",memories=tuple(memories))
        validate(out)
        return out
    if kind=="finish" and len(action)==1:
        result="won" if total(state)>=GOAL else "below"
        return replace(state,quote=None,ended=True,outcome=result,reason=result)
    return replace(state,reason="invalid")


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

    def reset(self):
        self.state=initial()
        self.selected=0
        self.confirm=False
        self.gated=False
        self.message="READY"

    def control(self,pressed,held):
        if "R" in pressed:
            self.reset()
            self.gated=True
            return
        if self.gated:
            if not pressed and not held:
                self.gated=False
            return
        if len(pressed | held)>1:
            self.gated=True
            self.message="RELEASE ALL KEYS"
            return
        if len(pressed)!=1:
            return
        key=next(iter(pressed))
        self.gated=True
        if self.state.ended:
            return
        if self.confirm:
            if key=="X":
                self.confirm=False
                self.message="RETURN CANCELLED"
            elif key=="Z":
                self.state=step(self.state,("finish",))
                self.confirm=False
                self.message=self.state.reason.upper()
            return
        if self.state.quote is not None:
            if key in ("Z","X"):
                self.state=step(self.state,("buy" if key=="Z" else "pass",))
                self.message=self.state.reason.upper()
            else:
                self.message="QUOTE OPEN: Z BUY / X PASS"
            return
        if key=="F":
            self.confirm=True
            self.message="RETURN NOW? Z YES / X CANCEL"
        elif key in ("LEFT","RIGHT"):
            self.selected=(self.selected+(-1 if key=="LEFT" else 1))%6
            self.message="SELECT ONLY"
        elif key in tuple(str(n) for n in range(1,7)):
            self.selected=int(key)-1
            self.message="SELECT ONLY"
        elif key=="Z":
            self.state=step(self.state,("quote",self.selected//2,self.selected%2))
            self.message=self.state.reason.upper()

    def draw(self,p):
        state=self.state
        p.cls(1)
        p.text(8,8,"FICTIONAL MARKET / STAGE "+str(STAGE),10)
        p.text(8,23,"CASH "+str(state.cash)+"  BAG VALUE "+str(bag_value(state))+"  TOTAL "+str(total(state))+" / GOAL "+str(GOAL),7)
        p.text(8,39,"SPENT "+str(state.spent)+"   BAG "+str(len(state.bag))+" / "+str(BAG_LIMIT),11)
        for n in range(6):
            shop,item=divmod(n,2)
            x=8+shop*164;y=62+item*43
            p.rect(x,y,156,37,2 if n==self.selected else 1)
            p.rectb(x,y,156,37,10 if n==self.selected else 5)
            p.text(x+6,y+6,str(n+1)+" "+"ABC"[shop]+" / "+ITEMS[item],7)
            p.text(x+6,y+20,"BASE "+str(BASE[shop][item])+" / "+("STOCK" if state.stock[n] else "SOLD"),6)
        bag="  ".join("ABC"[shop]+":"+ITEMS[item] for shop,item in state.bag) or "EMPTY"
        p.text(8,156,"BAG ORIGINS: "+bag,7)
        p.text(8,173,"VALUE = 3 + 2 PER EMPTY SAME-ITEM NEIGHBOR",11)
        q=state.quote
        if q:
            p.text(8,194,"LOCKED QUOTE: "+"ABC"[q.shop]+" "+ITEMS[q.item]+" / PRICE "+str(q.price),10)
            blocked="MONEY" if state.cash<q.price else "FULL" if len(state.bag)>=BAG_LIMIT else ""
            if blocked:
                p.text(8,210,"BUY REFUSED: "+blocked+" / X: PASS",8)
            else:
                forecast=preview(state)
                p.text(8,210,"IF BOUGHT: NEW "+str(forecast['new_item_value'])+" / OLD GAIN "+str(forecast['existing_gain'])+" / TOTAL "+str(forecast['final_total']),11)
        else:
            p.text(8,194,"Z OPENS QUOTE. NEXT Z PAYS LOCKED PRICE.",10)
        for shop in range(3):
            visible=" / ".join(candidates(state.history[shop],BASE[shop]))
            p.text(8,237+shop*15,"ABC"[shop]+" POSSIBLE: "+visible,6)
        p.text(8,292,self.message,14)
        p.text(8,309,"RESULT: "+(state.outcome.upper() if state.ended else "SHOPPING"),10)
        p.text(8,330,"ARROWS / 1-6 SELECT   Z ACT   X PASS/CANCEL",7)
        p.text(8,346,"F RETURN CONFIRMATION   R RESET   ESC QUIT",7)
        p.text(8,362,"NO UNDO / NO RESELL / NO REAL MONEY",6)

DEMO_KEYS = ('1', 'Z', 'Z', '2', 'Z')


def main(headless=False,screenshot=None,demo=False):
    import pyxel as p
    p.init(508,382,title="Fictional market lesson "+str(STAGE),fps=30,display_scale=2,headless=headless)
    app=App()
    keys={"LEFT":p.KEY_LEFT,"RIGHT":p.KEY_RIGHT,"Z":p.KEY_Z,"X":p.KEY_X,"F":p.KEY_F,"R":p.KEY_R}
    keys.update({str(n):getattr(p,"KEY_"+str(n)) for n in range(1,7)})
    limit=max(16,2*len(DEMO_KEYS)+6) if demo else 16
    def update():
        if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit:
            if screenshot:
                app.draw(p)
                p.screen.save(screenshot,1)
            p.quit()
        if headless:
            if demo:
                index=p.frame_count//2
                pressed={DEMO_KEYS[index]} if p.frame_count%2==0 and index<len(DEMO_KEYS) 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=__doc__)
    parser.add_argument("--headless-smoke",action="store_true")
    parser.add_argument("--demo-checkpoint",action="store_true")
    parser.add_argument("--screenshot")
    args=parser.parse_args()
    if args.demo_checkpoint and not args.headless_smoke:
        parser.error("--demo-checkpoint requires --headless-smoke")
    if args.screenshot and not args.headless_smoke:
        parser.error("--screenshot requires --headless-smoke")
    if args.screenshot and (Path(args.screenshot).suffix.lower()!=".png" or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):
        parser.error("use a new .png file in an existing directory")
    if args.screenshot:args.screenshot=str(Path(args.screenshot).resolve())
    main(args.headless_smoke,args.screenshot,args.demo_checkpoint)
AFTER STEP 05

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

元のゲームのBです。初値は ((1,3),(4,3),(3,3))、財布は10、袋は3個まで、目標は15です。店主は、自分の店で実際に買われたか見送られたかによって、一度だけ記憶を変えます。提示された値段は、買うまで固定です。品の価値は、A—B—C と並ぶとなりの店の、同じ品の在庫から計算し直し、残りのお金と合わせて合計を出します。限られた品だけの架空の市場で、現実の投資や値段の予想ではありません。

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

比べる点5ステップの教材元の完成版
段階01はすぐに買う・値段は固定、02は提示を保存。03は店主の変化。01〜03は価値3・目標12。04と05は元のゲームのBのふつうのルールで、05には見た履歴から出した候補の表示がある。A・B・C。となりのボーナスだけを切り替える比較と、同じ補助の目標12。
操作数字1〜6で品を選ぶ。提示中は、品の選択と帰宅ができない(ロック)。F→別に押したZで帰宅、Xで取り消し。新しく押したRを優先し、ふつうは複数のキーの同時押しを受け付けない。左右キーで店、上下キーで品を選ぶ。SPACEで帰宅の確認。表紙・一時停止・面の切り替えもある。
表示と保存英数字と、標準の図形だけで表示。ファイルの保存やUndoはない。05は、見たすべての出来事から候補だけを計算する。日本語の文字、過去の値段の表示、操作の記録(ログ)やGIF、方策(買い方の作戦)の検証。

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

  1. model.py の Quote・step(提示した値段の固定と、お金と品を同時に動かす部分)
  2. model.py の price_policy・remember(一度だけの値段の変化)
  3. model.py の candidates(正解のタイプを受け取らない作り)
  4. model.py の item_value・preview(新しく買う品と、もう持っている品の価値)
  5. experiments.py の、経済状態の同値類(同じ扱いになる状態のまとまり)と、反実仮想(もし別の選び方をしていたら)の比較
自分のゲームに変えるなら

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

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