推理・観察LESSON 39架空の予報・候補集合と意思決定原作080

予報士の机の作り方

分からない分だけ、幅のある予報にする。
見えている候補と観測から予報を選びます。分数で正確に計算した「平均でとれる点(期待点)」と、初めての挑戦・答えを知ったあとのやり直しの記録を分けるゲームを作ります。

完成版の遊び方風か雲を1つだけ観測し、手がかりに合う予報の幅を3日分選びます。初めての挑戦と、やり直しの記録は別々に残します。

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

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

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

完成版の主な操作:矢印観測・天気/Z選択、Enter提出、Space翌日、Tab期待点、X戻す、R同じ三日

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

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

うまく表示されないとき

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

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

候補の数から、正確な期待点を求める。

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

1. tuple・カードの番号・数え上げ・どれも同じ確率で出るカードの束
2. かくれた状態と見えている情報・条件で絞りこむ・1日1種類の観測
3. ビットで表す集合・空にしない決まり・「予報」と「結果」の2つの場面・3日分の報告
それだけで動く小さなプログラムを、5つのステップで少しずつ大きくしていきます。
必要な環境
Python 3.11以上 / Pyxel 2.9.9
知っておくとよいこと
Python 3.11以上、.venv とターミナル / 変数・if・for・関数・tuple・dict の基本 / dataclass・ビットで表す集合・Fraction(分数)・履歴は本文で説明します
教材に入っているもの
ステップごとに動くPythonファイル5本・説明・動作確認用のテスト
完成した教材の操作
['01: 矢印=参照、R=参照を#1へ', '02: 矢印=風/雲、Z=一回観測', '03〜04: 矢印=5欄、Z=観測/天気切替、Enter=提出、Space=次の日', '04〜05: Tab=全7予報の支援比較', '05: Z/Enter/Spaceで開始、X=Undo、R=同じ3日、B=比較版、H/P=説明、M=表紙', '共通: Escape=終了、操作キーを全て離して次へ。同時押しは拒否']
難易度の目安
中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
学習順に迷ったら
おすすめの学ぶ順番を見る
この講座の進め方① ページ下の教材ZIPをダウンロードして、すべて展開します。② はじめる準備を見ながら、PythonとPyxelを使える状態にします。③ まず 01_card_bank.py を、何も変えずにそのまま実行してみましょう。記事に載せているコードは説明用の一部分なので、実行するときはZIPに入っているファイルを使ってください。
コードを変更したら

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

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

STEP 01 / 05

公開カードを重複も含めて数える

tuple・カードの番号・数え上げ・どれも同じ確率で出るカードの束。

元のゲームの状況 C「雲だけでは決まらない日」の8枚を、そのまま使います。1枚は (天気, 風, 雲) の tuple です。天気は 0=晴れ、1=くもり、2=雨、風は 0=静か・1=強い、雲は 0=うすい・1=厚い です。画面の SUN/OVERCAST/RAIN は、この順番に対応します。Python の番号は0から、画面のカード番号は1から数えます。#1 と #3 は中身が同じでも別々の1枚なので、set にして重なりを消してはいけません。どのカードも重みは 1/8 なので、「晴れ3枚・くもり2枚・雨3枚」という数が、予報を考える出発点です。counts_for は候補の番号を受け取り、天気ごとに数えます。この段階は見えている表を見るだけで、かくしたカードを引くことも、点数を付けることもしません。

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

01_card_bank.py / 注目する部分
def counts_for(level, candidates):
    return tuple(sum(level.cards[i][0] == w for i in candidates) for w in range(3))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 01_card_bank.py
macOS / Linux
.venv/bin/python 01_card_bank.py
画面で見ること起動すると、#1 を黄色い枠で見ています。右を2回押すと #3 に移り、#1 と同じ SUN/CALM/THIN だと分かります。残りの枚数は8、SUN 3 / OVER 2 / RAIN 3 のままです。左はしで左を押すと、#8 に一周します。見ているカーソルは、今日の答えを指しているわけではありません。
  • 8枚は同じ重みで、6種類とは区別する
  • 画面の番号は1から、プログラムの番号は0から
  • 候補の番号を保存し、カードを増やしたり消したりしない
  • 見ているカーソルから、本当の今日を推測しない
つまずきやすいところ同じ tuple を1枚にまとめると6種類になってしまい、元の「8枚が同じ確率」という模型が変わります。画面の #3 を cards[3] で読むと、#4 を見てしまいます。カーソルと、かくされた答えも別のものです。
少し変えてみよう

右 → 右 で #3 を見て、#1 と重なっていることを確かめます。R → 左 で #8 に一周することを確かめ、紙に「晴れ 3/8・くもり 2/8・雨 3/8」と書いて、合計が1になるか調べてください。8枚から重なりを消すと種類は6ですが、この6は確率の分母には使いません。

01_card_bank.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(196行)
01_card_bank.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.
# 教材はASCIIと図形のみ。Noto字形データは含みません。

"""予報士の机・教材1/5。LESSON.mdを参照。単独起動可能、画面はASCII。"""
from dataclasses import dataclass, replace, asdict
import random

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    seed: int
    cards: tuple

# 原作Cをそのまま採用。各行が等確率の1枚。重複をsetにしない。
LEVEL = Level('C', '雲だけでは決まらない日', 757,
              ((0, 0, 0), (0, 0, 1), (0, 0, 0), (1, 0, 1),
               (1, 1, 0), (2, 1, 0), (2, 1, 1), (2, 1, 0)))
WEATHER = ('SUN', 'OVERCAST', 'RAIN')
WIND = ('CALM', 'STRONG')
CLOUD = ('THIN', 'THICK')


def candidates_for(level, observed='', value=-1):
    if not observed:
        return tuple(range(len(level.cards)))
    index = {'wind': 1, 'cloud': 2}[observed]
    return tuple(i for i, card in enumerate(level.cards) if card[index] == value)


def counts_for(level, candidates):
    return tuple(sum(level.cards[i][0] == w for i in candidates) for w in range(3))


def draw_bank(pyxel, candidates, focus=None):
    # ここへ秘密の今日のカードを渡さない。focusは01の参照カーソルのみ。
    pyxel.text(16, 48, 'PUBLIC BANK / 8 EQUALLY WEIGHTED CARDS', 10)
    for i, (weather, wind, cloud) in enumerate(LEVEL.cards):
        x = 16 + (i % 2) * 154
        y = 69 + (i // 2) * 56
        active = i in candidates
        color = 7 if active else 5
        pyxel.rect(x, y, 145, 47, 1)
        pyxel.rectb(x, y, 145, 47, 11 if active else 5)
        if i == focus:
            pyxel.rectb(x-2, y-2, 149, 51, 10)
        pyxel.text(x+8, y+8, f'#{i+1} '+WEATHER[weather], color)
        pyxel.text(x+8, y+21, 'WIND: '+WIND[wind], color)
        pyxel.text(x+8, y+34, 'CLOUD: '+CLOUD[cloud], color)
    counts = counts_for(LEVEL, candidates)
    pyxel.text(16, 307, f'REMAIN: {len(candidates)}   SUN {counts[0]} / OVER {counts[1]} / RAIN {counts[2]}', 11)

STAGE = 1
STAGE_TITLE = 'PUBLIC CARD BANK'
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'R')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'R': 'R'}

class Controller:
    def __init__(self):
        self.focus = 0
        self.previous = set()
        self.gate = False
        self.notice = ''

    def handle(self, key):
        if key == 'R':
            self.focus = 0
            return 'reset_cursor'
        if key in ('LEFT', 'UP', 'RIGHT', 'DOWN'):
            self.focus = (self.focus + (-1 if key in ('LEFT', 'UP') else 1)) % 8
            return 'inspect'

    def snapshot(self):
        return {'focus': self.focus, 'card': LEVEL.cards[self.focus],
                'candidates': tuple(range(8)), 'counts': counts_for(LEVEL, range(8)),
                'notice': self.notice}

    def feed(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.notice = 'ONE KEY AT A TIME'
            return 'reject:chord'
        if not fresh:
            return None
        self.gate = True
        self.notice = ''
        return self.handle(next(iter(fresh)))


def draw_board(pyxel, controller):
    pyxel.cls(0)
    pyxel.text(16, 13, 'FORECASTER DESK / 01 PUBLIC CARD BANK', 10)
    pyxel.text(16, 30, 'Fictional classroom model. Not real weather or financial forecasting.', 6)
    draw_bank(pyxel, range(8), controller.focus)
    pyxel.text(342, 70, 'LOOK AT PUBLIC CARDS', 10)
    pyxel.text(342, 94, f'INSPECT: #{controller.focus+1}', 11)
    pyxel.text(342, 120, '#1 and #3 are two cards.', 7)
    pyxel.text(342, 139, 'Never remove duplicates.', 7)
    pyxel.text(342, 165, 'No secret is drawn in step 01.', 6)
    pyxel.text(342, 184, "A cursor is not today's card.", 6)
    pyxel.text(16, 345, controller.notice or 'ARROWS: inspect card   R: cursor to #1   ESC: quit', 10)
    pyxel.text(16, 371, 'SUN=0 / OVERCAST=1 / RAIN=2    WIND: 0=CALM 1=STRONG', 7)
    pyxel.text(16, 389, 'CLOUD: 0=THIN 1=THICK / Counting lab, no score or submission yet.', 6)


def main(headless=False, actions='', screenshot=None, report=None):
    # 引数検証が済むまでPyxelをimportしない。モデル試験は標準ライブラリのみ。
    import json
    import hashlib
    from pathlib import Path
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel
    controller = Controller()
    codes = {name: getattr(pyxel, 'KEY_'+name) for name in KEYS}
    frame = 0
    events = []
    last_frame = max(12, len(actions)*2+2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame-1)//2
            down = {REPLAY[actions[index]]} if frame%2 and index<len(actions) else set()
        else:
            down = {name for name, code in codes.items() if pyxel.btn(code)}
        event = controller.feed(down)
        if event:
            events.append({'frame': frame, 'event': event, 'public': controller.snapshot()})

    def draw():
        draw_board(pyxel, controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless; synthetic Controller.feed inputs; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                    'events': events, 'snapshot': controller.snapshot(),
                }, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(640, 440, title=f'Forecaster Desk - Step {STAGE}', fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic keys: '+str(REPLAY))
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    actions = args.headless_replay or ''
    if any(action not in REPLAY for action in actions):
        parser.error('Unsupported replay character')
    main(headless, actions, args.screenshot, args.report)
STEP 02 / 05

一つの観測から候補集合を作る

かくれた状態と見えている情報・条件で絞りこむ・1日1種類の観測。

元のゲームと同じ random.Random(seed) で、3日分を「戻して引く」方法で選びます。「戻して引く」とは、昨日のカードを箱から取り除かず、毎回8枚から選ぶことです。この段階は1日目の観測だけを扱います。矢印で風か雲を選び、Z で1種類を観測します。observe はその項目の値だけを読み、candidates_for で、見えている8枚のうち同じ値を持つカードを残します。残ったカードは全部「ありうる答え」です。カードの番号・観測していない項目・本当の天気は、public_view(見せる情報)には渡しません。2種類目まで観測できると別の条件になってしまうので、一度観測した日はやり直せません。01 の R は見る位置を戻すだけでしたが、この段階では R を外して、観測した情報を消せるように見せないようにしています。

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

02_observation.py / 注目する部分
    return tuple(i for i, card in enumerate(level.cards) if card[index] == value)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 02_observation.py
macOS / Linux
.venv/bin/python 02_observation.py
画面で見ること起動すると候補は8枚で、OBS: NONE(観測なし)、VALUE: UNKNOWN(不明)です。右 → Z で雲を観測する手順では THICK(厚い)と出て、#2/#4/#7 の3枚が残り、晴れ・くもり・雨が1枚ずつです。新しく起動して Z で風を観測する手順では STRONG(強い)と出て、#5/#6/#7/#8 が残り、晴れ0・くもり1・雨3です。これは観測の結果を確かめただけで、未来を当てたわけではありません。
  • 観測できるのは、風か雲の1種類だけ
  • 候補には、中身が重なるカードも残す
  • 観測する前は分母8、観測したあとは残った枚数
  • 見せる表示や JSON に、未来のひみつを渡さない
つまずきやすいところ候補の天気を1種類に決めつけないようにしましょう。たとえば、厚い雲の3枚には晴れもふくまれます。観測したカードだけを1枚残したり、先にひみつのカード番号を public_view に入れたりすると、問題を解く前に答えを渡してしまいます。
少し変えてみよう

右 → Z で厚い雲の3枚を数え、そのあと左 → Z で風も観測しようとしても、観測が cloud(雲)のままであることを確かめましょう。紙の表で、静かな風なら #1/#2/#3/#4、強い風なら #5/#6/#7/#8、うすい雲なら #1/#3/#5/#6/#8 と書き出してみてください。アプリを閉じても、あなたの記憶は消えません。

02_observation.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(227行)
02_observation.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.
# 教材はASCIIと図形のみ。Noto字形データは含みません。

"""予報士の机・教材2/5。LESSON.mdを参照。単独起動可能、画面はASCII。"""
from dataclasses import dataclass, replace, asdict
import random

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    seed: int
    cards: tuple

# 原作Cをそのまま採用。各行が等確率の1枚。重複をsetにしない。
LEVEL = Level('C', '雲だけでは決まらない日', 757,
              ((0, 0, 0), (0, 0, 1), (0, 0, 0), (1, 0, 1),
               (1, 1, 0), (2, 1, 0), (2, 1, 1), (2, 1, 0)))
WEATHER = ('SUN', 'OVERCAST', 'RAIN')
WIND = ('CALM', 'STRONG')
CLOUD = ('THIN', 'THICK')


def candidates_for(level, observed='', value=-1):
    if not observed:
        return tuple(range(len(level.cards)))
    index = {'wind': 1, 'cloud': 2}[observed]
    return tuple(i for i, card in enumerate(level.cards) if card[index] == value)


def counts_for(level, candidates):
    return tuple(sum(level.cards[i][0] == w for i in candidates) for w in range(3))


def draw_bank(pyxel, candidates, focus=None):
    # ここへ秘密の今日のカードを渡さない。focusは01の参照カーソルのみ。
    pyxel.text(16, 48, 'PUBLIC BANK / 8 EQUALLY WEIGHTED CARDS', 10)
    for i, (weather, wind, cloud) in enumerate(LEVEL.cards):
        x = 16 + (i % 2) * 154
        y = 69 + (i // 2) * 56
        active = i in candidates
        color = 7 if active else 5
        pyxel.rect(x, y, 145, 47, 1)
        pyxel.rectb(x, y, 145, 47, 11 if active else 5)
        if i == focus:
            pyxel.rectb(x-2, y-2, 149, 51, 10)
        pyxel.text(x+8, y+8, f'#{i+1} '+WEATHER[weather], color)
        pyxel.text(x+8, y+21, 'WIND: '+WIND[wind], color)
        pyxel.text(x+8, y+34, 'CLOUD: '+CLOUD[cloud], color)
    counts = counts_for(LEVEL, candidates)
    pyxel.text(16, 307, f'REMAIN: {len(candidates)}   SUN {counts[0]} / OVER {counts[1]} / RAIN {counts[2]}', 11)

STAGE = 2
STAGE_TITLE = 'ONE OBSERVATION'
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'Z': 'Z'}

@dataclass(frozen=True)
class State:
    secrets: tuple
    observed: str = ''
    value: int = -1
    candidates: tuple = tuple(range(8))


def new(level=LEVEL):
    rng = random.Random(level.seed)
    return State(tuple(rng.randrange(8) for _ in range(3)))


def observe(level, state, attribute):
    if state.observed:
        return state
    index = {'wind': 1, 'cloud': 2}[attribute]
    value = level.cards[state.secrets[0]][index]
    return replace(state, observed=attribute, value=value,
                   candidates=candidates_for(level, attribute, value))


def public_view(level, state):
    return {'observed': state.observed, 'value': state.value,
            'candidates': state.candidates, 'counts': counts_for(level, state.candidates)}

class Controller:
    def __init__(self):
        self.state = new()
        self.focus = 0
        self.previous = set()
        self.gate = False
        self.notice = ''

    def handle(self, key):
        if key in ('LEFT', 'RIGHT', 'UP', 'DOWN'):
            self.focus = 1-self.focus
            return 'select'
        if key == 'Z':
            if self.state.observed:
                self.notice = 'ONE OBSERVATION PER DAY'
                return 'rejected:observed'
            self.state = observe(LEVEL, self.state, ('wind', 'cloud')[self.focus])
            return 'observe'

    def snapshot(self):
        return dict(public_view(LEVEL, self.state), focus=self.focus, notice=self.notice)

    def feed(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.notice = 'ONE KEY AT A TIME'
            return 'reject:chord'
        if not fresh:
            return None
        self.gate = True
        self.notice = ''
        return self.handle(next(iter(fresh)))


def draw_board(pyxel, controller):
    pyxel.cls(0)
    pyxel.text(16, 13, 'FORECASTER DESK / 02 ONE OBSERVATION', 10)
    pyxel.text(16, 30, 'Fictional classroom model / day 1 / actual card remains hidden.', 6)
    p = controller.snapshot()
    draw_bank(pyxel, p['candidates'])
    for i, label in enumerate(('OBSERVE WIND', 'OBSERVE CLOUD')):
        pyxel.text(342, 70+i*24, ('> ' if i == controller.focus else '  ')+label, 10)
    pyxel.text(342, 129, 'OBSERVED: '+(p['observed'].upper() or 'NONE'), 11)
    value = 'UNKNOWN' if not p['observed'] else (WIND if p['observed']=='wind' else CLOUD)[p['value']]
    pyxel.text(342, 148, 'VALUE: '+value, 11)
    pyxel.text(342, 180, 'A match remains a candidate.', 7)
    pyxel.text(342, 199, 'Other properties stay hidden.', 7)
    pyxel.text(342, 227, 'No submit or score in step 02.', 6)
    pyxel.text(16, 345, controller.notice or 'ARROWS: wind/cloud   Z: observe once   ESC: quit', 10)
    pyxel.text(16, 371, "Do not filter yesterday's candidates. Every new day starts with eight.", 7)
    pyxel.text(16, 389, 'No reset here: observing is knowledge, not a reversible cursor move.', 6)


def main(headless=False, actions='', screenshot=None, report=None):
    # 引数検証が済むまでPyxelをimportしない。モデル試験は標準ライブラリのみ。
    import json
    import hashlib
    from pathlib import Path
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel
    controller = Controller()
    codes = {name: getattr(pyxel, 'KEY_'+name) for name in KEYS}
    frame = 0
    events = []
    last_frame = max(12, len(actions)*2+2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame-1)//2
            down = {REPLAY[actions[index]]} if frame%2 and index<len(actions) else set()
        else:
            down = {name for name, code in codes.items() if pyxel.btn(code)}
        event = controller.feed(down)
        if event:
            events.append({'frame': frame, 'event': event, 'public': controller.snapshot()})

    def draw():
        draw_board(pyxel, controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless; synthetic Controller.feed inputs; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                    'events': events, 'snapshot': controller.snapshot(),
                }, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(640, 440, title=f'Forecaster Desk - Step {STAGE}', fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic keys: '+str(REPLAY))
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    actions = args.headless_replay or ''
    if any(action not in REPLAY for action in actions):
        parser.error('Unsupported replay character')
    main(headless, actions, args.screenshot, args.report)
STEP 03 / 05

予報の幅と採点を分けて実装する

ビットで表す集合・空にしない決まり・「予報」と「結果」の2つの場面・3日分の報告。

風・雲・晴れ・くもり・雨の5つの欄を矢印で選び、Z で観測するか、天気を予報に入れる・外すを切りかえます。forecast は、晴れ=1、くもり=2、雨=4 を足した「ビットで表す集合」です。晴れと雨なら 1+4=5 で、順番や、続いた数の範囲ではありません。ここでいう予報の幅は、ふくめる天気の種類の数です。mask.bit_count() で幅を数え、mask >> actual & 1 で結果がふくまれているかを調べます。1種類なら当たり+6/外れ-4、2種類なら+2/-2、全部なら必ず+1 です。0(空)だけは提出できません。観測なし、全部の天気、候補にない天気、期待点が低そうな選択も、空でなければ受け付けます。Enter で結果へ、Space で次の日へ進みます。3日分報告したら、点数に関係なく完了です。

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

03_forecast_width.py / 注目する部分
            return replace(state, forecast=state.forecast ^ (1 << weather))

実行して確かめる

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

Windows
.venv\Scripts\python.exe 03_forecast_width.py
macOS / Linux
.venv/bin/python 03_forecast_width.py
画面で見ることEnter → Space → Enter → Space → Enter と押すと、観測なしで「全部」の予報を3回提出し、合計+3で完了します。結果の画面ではじめて、本当の天気・カード番号・風・雲が表示されます。結果の画面で Enter や Z を何回押しても、先へは進みません。次の日は、また8枚の候補から始まります。この段階には、R・X・B・期待点の表は、まだありません。
  • 1/2/4 のビットで、晴れ・くもり・雨を別々に選ぶ
  • 空(0)の予報だけを受け付けない
  • 結果を読んでいる間に、勝手に次の日へ進まない
  • 3日報告すれば完了。高得点や一番いい選び方は必要ない
つまずきやすいところ当たりや高得点を完了の条件にしないようにしましょう。元のゲームの仕事は「3日分報告すること」です。また、候補にない天気をボタンから消すと、自由に幅を決められるという元のルールが変わってしまいます。「空の予報を受け付けない」ことと、データがおかしくて候補が0枚になったときのエラーは、別のものです。
少し変えてみよう

右を2回 → Z で晴れを外し、右 → Z でくもりを外し、右 → Z で雨を外して、mask=0 にします。Enter が受け付けられないことを確かめ、同じ雨の欄で Z → Enter と押すと、「雨だけ」の正しい提出になることを確かめましょう。別に、「全部」の予報で3日提出すると合計+3になる手順も通してください。勝ち負けによる合格・不合格はありません。

03_forecast_width.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(299行)
03_forecast_width.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.
# 教材はASCIIと図形のみ。Noto字形データは含みません。

"""予報士の机・教材3/5。LESSON.mdを参照。単独起動可能、画面はASCII。"""
from dataclasses import dataclass, replace, asdict
import random

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    seed: int
    cards: tuple

# 原作Cをそのまま採用。各行が等確率の1枚。重複をsetにしない。
LEVEL = Level('C', '雲だけでは決まらない日', 757,
              ((0, 0, 0), (0, 0, 1), (0, 0, 0), (1, 0, 1),
               (1, 1, 0), (2, 1, 0), (2, 1, 1), (2, 1, 0)))
WEATHER = ('SUN', 'OVERCAST', 'RAIN')
WIND = ('CALM', 'STRONG')
CLOUD = ('THIN', 'THICK')


def candidates_for(level, observed='', value=-1):
    if not observed:
        return tuple(range(len(level.cards)))
    index = {'wind': 1, 'cloud': 2}[observed]
    return tuple(i for i, card in enumerate(level.cards) if card[index] == value)


def counts_for(level, candidates):
    return tuple(sum(level.cards[i][0] == w for i in candidates) for w in range(3))


def draw_bank(pyxel, candidates, focus=None):
    # ここへ秘密の今日のカードを渡さない。focusは01の参照カーソルのみ。
    pyxel.text(16, 48, 'PUBLIC BANK / 8 EQUALLY WEIGHTED CARDS', 10)
    for i, (weather, wind, cloud) in enumerate(LEVEL.cards):
        x = 16 + (i % 2) * 154
        y = 69 + (i // 2) * 56
        active = i in candidates
        color = 7 if active else 5
        pyxel.rect(x, y, 145, 47, 1)
        pyxel.rectb(x, y, 145, 47, 11 if active else 5)
        if i == focus:
            pyxel.rectb(x-2, y-2, 149, 51, 10)
        pyxel.text(x+8, y+8, f'#{i+1} '+WEATHER[weather], color)
        pyxel.text(x+8, y+21, 'WIND: '+WIND[wind], color)
        pyxel.text(x+8, y+34, 'CLOUD: '+CLOUD[cloud], color)
    counts = counts_for(LEVEL, candidates)
    pyxel.text(16, 307, f'REMAIN: {len(candidates)}   SUN {counts[0]} / OVER {counts[1]} / RAIN {counts[2]}', 11)

STAGE = 3
STAGE_TITLE = 'FORECAST WIDTH'
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'RETURN', 'SPACE')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'Z': 'Z', 'E': 'RETURN', 'S': 'SPACE'}

def payoff(mask, actual, mode='base'):
    width = mask.bit_count()
    hit = bool(mask >> actual & 1)
    if width == 1:
        return 6 if hit else (-1 if mode == 'compare' else -4)
    if width == 2:
        return 2 if hit else -2
    if width == 3:
        return 1
    raise ValueError('empty forecast')

@dataclass(frozen=True)
class Report:
    day: int
    observed: str
    value: int
    candidates: tuple
    forecast: int
    actual: int
    card: int
    score: int

@dataclass(frozen=True)
class State:
    secrets: tuple
    day: int = 0
    phase: str = 'forecast'
    observed: str = ''
    value: int = -1
    candidates: tuple = tuple(range(8))
    forecast: int = 7
    reports: tuple = ()
    result: object = None


def new(level=LEVEL):
    rng = random.Random(level.seed)
    return State(tuple(rng.randrange(8) for _ in range(3)))


def act(level, state, action):
    if action.startswith('observe:') and state.phase == 'forecast':
        if state.observed:
            return state
        attr = action.split(':')[1]
        index = {'wind': 1, 'cloud': 2}[attr]
        value = level.cards[state.secrets[state.day]][index]
        return replace(state, observed=attr, value=value,
                       candidates=candidates_for(level, attr, value))
    if action.startswith('toggle:') and state.phase == 'forecast':
        weather = int(action.split(':')[1])
        if weather in range(3):
            return replace(state, forecast=state.forecast ^ (1 << weather))
    if action == 'submit' and state.phase == 'forecast':
        if not state.candidates:
            raise ValueError('candidate table inconsistent')
        if state.forecast == 0:
            return state
        card = state.secrets[state.day]
        actual = level.cards[card][0]
        report = Report(state.day, state.observed, state.value, state.candidates,
                        state.forecast, actual, card, payoff(state.forecast, actual))
        return replace(state, phase='result', result=report, reports=state.reports+(report,))
    if action == 'next' and state.phase == 'result' and state.day < 2:
        return State(state.secrets, day=state.day+1, reports=state.reports)
    return state


def public_view(level, state):
    # JSON記録も画面と同じ公開情報だけ。次の日の秘密は記録しない。
    return {'day': state.day, 'phase': state.phase, 'observed': state.observed,
            'value': state.value, 'candidates': state.candidates,
            'forecast': state.forecast, 'counts': counts_for(level, state.candidates),
            'result': asdict(state.result) if state.result else None,
            'first': [asdict(r) for r in state.reports], 'practice': [],
            'total': sum(r.score for r in state.reports), 'complete': len(state.reports)==3,
            'replay': False, 'result_practice': False}

class Controller:
    def __init__(self):
        self.state = new()
        self.focus = 0
        self.previous = set()
        self.gate = False
        self.notice = ''

    def handle(self, key):
        if key in ('LEFT', 'RIGHT', 'UP', 'DOWN') and self.state.phase == 'forecast':
            self.focus = (self.focus + (-1 if key in ('LEFT', 'UP') else 1)) % 5
            return 'select'
        action = {'RETURN': 'submit', 'SPACE': 'next'}.get(key)
        if key == 'Z' and self.state.phase == 'forecast':
            action = ('observe:wind', 'observe:cloud', 'toggle:0', 'toggle:1', 'toggle:2')[self.focus]
        if action:
            before = self.state
            self.state = act(LEVEL, self.state, action)
            if self.state == before:
                self.notice = 'IGNORED / CHECK PHASE OR EMPTY FORECAST'
            return action if self.state != before else 'ignored:'+action

    def snapshot(self):
        return dict(public_view(LEVEL, self.state), focus=self.focus, notice=self.notice)

    def feed(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.notice = 'ONE KEY AT A TIME'
            return 'reject:chord'
        if not fresh:
            return None
        self.gate = True
        self.notice = ''
        return self.handle(next(iter(fresh)))
def mask_name(mask):
    return ''.join(letter if mask & (1 << i) else '-' for i, letter in enumerate('SOR'))


def draw_board(pyxel, controller):
    pyxel.cls(0)
    pyxel.text(16, 13, f'FORECASTER DESK / {STAGE:02} '+STAGE_TITLE, 10)
    pyxel.text(16, 30, 'Fictional tabletop model. Not real weather or financial forecasting.', 6)
    p = controller.snapshot()
    draw_bank(pyxel, p['candidates'])
    pyxel.text(342, 48, f'DAY {p["day"]+1}/3  '+p['phase'].upper(), 11)
    for i, label in enumerate(('WIND', 'CLOUD', 'SUN', 'OVERCAST', 'RAIN')):
        selected = i < 2 or bool(p['forecast'] & (1 << (i-2)))
        text = ('> ' if p['focus']==i else '  ')+label
        if i >= 2:
            text += ' [ON]' if selected else ' [OFF]'
        pyxel.text(342, 69+i*17, text, 10 if p['focus']==i else 7 if selected else 5)
    observed = p['observed']
    value = 'UNKNOWN' if not observed else (WIND if observed=='wind' else CLOUD)[p['value']]
    pyxel.text(342, 161, 'OBS: '+(observed.upper() or 'NONE')+' '+value, 11)
    pyxel.text(342, 179, 'MASK: '+mask_name(p['forecast'])+' / '+str(p['forecast']), 7)
    pyxel.text(342, 197, 'EMPTY CANNOT SUBMIT' if not p['forecast'] else f"1: +6/{p.get('penalty', -4)}   2: +2/-2   3: +1", 8 if not p['forecast'] else 7)
    pyxel.text(342, 217, 'FIRST TOTAL: '+str(p['total'])+' / PRACTICE: '+str(len(p['practice'])), 11)
    if p['result']:
        r = p['result']
        pyxel.text(342, 239, 'REVEALED: '+WEATHER[r['actual']]+f' / SCORE {r["score"]:+}', 10)
    else:
        pyxel.text(342, 239, 'ANSWER KNOWN: REPLAY' if p['replay'] else 'TODAY / FUTURE ANSWERS HIDDEN', 8 if p['replay'] else 6)
    if p['result']:
        card = LEVEL.cards[p['result']['card']]
        pyxel.text(342, 257, f"CARD #{p['result']['card']+1} / "+WIND[card[1]]+' / '+CLOUD[card[2]], 7)
    pyxel.text(342, 277, 'COMPLETE: ANY SCORE IS VALID' if p['complete'] else 'Submit all three days at any score.', 11)
    pyxel.text(342, 295, 'PRACTICE RESULT' if p['result_practice'] else 'Observation optional. No timer penalty.', 8 if p['result_practice'] else 6)
    pyxel.text(16, 332, 'ARROWS: select  Z: observe/toggle  ENTER: submit  SPACE: next  ESC: quit', 7)
    pyxel.text(16, 350, p['notice'] or 'Release all action keys before the next action.', 10)
    pyxel.text(16, 370, 'S/O/R = SUN / OVERCAST / RAIN. Forecast is a set, not a numeric interval.', 6)


def main(headless=False, actions='', screenshot=None, report=None):
    # 引数検証が済むまでPyxelをimportしない。モデル試験は標準ライブラリのみ。
    import json
    import hashlib
    from pathlib import Path
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel
    controller = Controller()
    codes = {name: getattr(pyxel, 'KEY_'+name) for name in KEYS}
    frame = 0
    events = []
    last_frame = max(12, len(actions)*2+2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame-1)//2
            down = {REPLAY[actions[index]]} if frame%2 and index<len(actions) else set()
        else:
            down = {name for name, code in codes.items() if pyxel.btn(code)}
        event = controller.feed(down)
        if event:
            events.append({'frame': frame, 'event': event, 'public': controller.snapshot()})

    def draw():
        draw_board(pyxel, controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless; synthetic Controller.feed inputs; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                    'events': events, 'snapshot': controller.snapshot(),
                }, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(640, 440, title=f'Forecaster Desk - Step {STAGE}', fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic keys: '+str(REPLAY))
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    actions = args.headless_replay or ''
    if any(action not in REPLAY for action in actions):
        parser.error('Unsupported replay character')
    main(headless, actions, args.screenshot, args.report)
STEP 04 / 05

Fractionで全7予報の期待点を比べる

正確な分数・条件付きの平均・同点を残す比べ方・手助けの表示の記録。

Fraction(分数)を使って、候補のカードそれぞれでとれる点数を、候補の枚数で割って足します。整数から Fraction を作るので、小数の丸めで順位が入れかわることがありません。Tab で表示するのは、7つすべての予報について「当たる枚数」と「期待点」を見せる、教材用の手助けです。期待点は、同じ条件で何回もくり返したときの平均で、今日の点数や、決まった答えではありません。いちばん大きい値と同じ行には、全部 * を付け、同点のときに1つを勝手に選んだりしません。観測なしでは、「晴れ+雨」と「全部」の期待点がどちらも1で、同点です。厚い雲の3枚では、1種類が -2/3、2種類が 2/3、全部が1になります。手助けは表示するだけで、forecast は変えません。表を閉じても、その日に比べた表を見たことは、提出のときに assistance_records に残します。

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

04_expected_value.py / 注目する部分
    expected = sum(Fraction(payoff(mask, level.cards[i][0], mode), total)
                   for i in state.candidates)

実行して確かめる

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

Windows
.venv\Scripts\python.exe 04_expected_value.py
macOS / Linux
.venv/bin/python 04_expected_value.py
画面で見ること起動して Tab を押すと、S-R と SOR の2行に * が付きます。右 → Z で厚い雲を観測して Tab を押すと、SOR だけがいちばん大きい1です。晴れだけは -2/3 で、実際の当たり外れとは区別されます。表を閉じて提出しても、HELP SEEN TODAY(今日は手助けを見た)は True のままです。表を開いたまま次の日に進めば、次の日も「手助けあり」になります。
  • Fraction(整数,整数) で正確に計算する
  • 「当たる確率」と「点数の期待値」を分ける
  • 同点の最大は全部残し、いちばんではない選択も許す
  • 手助けの表示を使って提出したことを保存する
つまずきやすいところ「候補3枚のうち1枚が晴れだから、期待点は 1/3」としないようにしましょう。1/3 は当たる確率で、期待点は (6-4-4)/3=-2/3 です。いちばんの行だけを提出できるようにしたり、同点を小数の大小で切り捨てたりはしません。手助けを使って高得点をとったことを、「自分の力で初めて成功した」と呼ぶのもやめましょう。
少し変えてみよう

まず、観測なしで S-R の期待点を (6×2+2×(-2))/8=1 と求め、SOR の1と比べてみましょう。厚い雲なら、1種類 -2/3、2種類 2/3、全部1を、手で計算してください。表の静かな風4枚では、晴れだけが (3×6-4)/4=7/2、うすい雲5枚では、晴れ+雨が (4×2-2)/5=6/5 です。すべての数と同点は、test_stages.py で確かめています。

04_expected_value.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(361行)
04_expected_value.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.
# 教材はASCIIと図形のみ。Noto字形データは含みません。

"""予報士の机・教材4/5。LESSON.mdを参照。単独起動可能、画面はASCII。"""
from dataclasses import dataclass, replace, asdict
import random

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    seed: int
    cards: tuple

# 原作Cをそのまま採用。各行が等確率の1枚。重複をsetにしない。
LEVEL = Level('C', '雲だけでは決まらない日', 757,
              ((0, 0, 0), (0, 0, 1), (0, 0, 0), (1, 0, 1),
               (1, 1, 0), (2, 1, 0), (2, 1, 1), (2, 1, 0)))
WEATHER = ('SUN', 'OVERCAST', 'RAIN')
WIND = ('CALM', 'STRONG')
CLOUD = ('THIN', 'THICK')


def candidates_for(level, observed='', value=-1):
    if not observed:
        return tuple(range(len(level.cards)))
    index = {'wind': 1, 'cloud': 2}[observed]
    return tuple(i for i, card in enumerate(level.cards) if card[index] == value)


def counts_for(level, candidates):
    return tuple(sum(level.cards[i][0] == w for i in candidates) for w in range(3))


def draw_bank(pyxel, candidates, focus=None):
    # ここへ秘密の今日のカードを渡さない。focusは01の参照カーソルのみ。
    pyxel.text(16, 48, 'PUBLIC BANK / 8 EQUALLY WEIGHTED CARDS', 10)
    for i, (weather, wind, cloud) in enumerate(LEVEL.cards):
        x = 16 + (i % 2) * 154
        y = 69 + (i // 2) * 56
        active = i in candidates
        color = 7 if active else 5
        pyxel.rect(x, y, 145, 47, 1)
        pyxel.rectb(x, y, 145, 47, 11 if active else 5)
        if i == focus:
            pyxel.rectb(x-2, y-2, 149, 51, 10)
        pyxel.text(x+8, y+8, f'#{i+1} '+WEATHER[weather], color)
        pyxel.text(x+8, y+21, 'WIND: '+WIND[wind], color)
        pyxel.text(x+8, y+34, 'CLOUD: '+CLOUD[cloud], color)
    counts = counts_for(LEVEL, candidates)
    pyxel.text(16, 307, f'REMAIN: {len(candidates)}   SUN {counts[0]} / OVER {counts[1]} / RAIN {counts[2]}', 11)

STAGE = 4
STAGE_TITLE = 'EXACT EXPECTATION'
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'RETURN', 'SPACE', 'TAB')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'Z': 'Z', 'E': 'RETURN', 'S': 'SPACE', 'A': 'TAB'}

def payoff(mask, actual, mode='base'):
    width = mask.bit_count()
    hit = bool(mask >> actual & 1)
    if width == 1:
        return 6 if hit else (-1 if mode == 'compare' else -4)
    if width == 2:
        return 2 if hit else -2
    if width == 3:
        return 1
    raise ValueError('empty forecast')

from fractions import Fraction


def estimate(level, state, mask=None, mode='base'):
    mask = state.forecast if mask is None else mask
    total = len(state.candidates)
    if not total:
        raise ValueError('candidate table inconsistent')
    if not mask:
        return None
    hit = sum(bool(mask >> level.cards[i][0] & 1) for i in state.candidates)
    expected = sum(Fraction(payoff(mask, level.cards[i][0], mode), total)
                   for i in state.candidates)
    return {'hit': hit, 'total': total, 'numerator': expected.numerator,
            'denominator': expected.denominator,
            'hit_score': 6 if mask.bit_count()==1 else 2 if mask.bit_count()==2 else 1,
            'miss_score': (-1 if mode=='compare' else -4) if mask.bit_count()==1
                          else -2 if mask.bit_count()==2 else 1}


def comparison_rows(level, state, mode='base'):
    # 補助計算。全て公開候補から計算し、秘密カードを参照しない。
    values = [(mask, estimate(level, state, mask, mode)) for mask in range(1, 8)]
    best = max(Fraction(e['numerator'], e['denominator']) for _, e in values)
    return [dict(mask=mask, **e, best=Fraction(e['numerator'], e['denominator'])==best)
            for mask, e in values]

@dataclass(frozen=True)
class Report:
    day: int
    observed: str
    value: int
    candidates: tuple
    forecast: int
    actual: int
    card: int
    score: int
    expected_num: int
    expected_den: int

@dataclass(frozen=True)
class State:
    secrets: tuple
    day: int = 0
    phase: str = 'forecast'
    observed: str = ''
    value: int = -1
    candidates: tuple = tuple(range(8))
    forecast: int = 7
    reports: tuple = ()
    result: object = None


def new(level=LEVEL):
    rng = random.Random(level.seed)
    return State(tuple(rng.randrange(8) for _ in range(3)))


def act(level, state, action):
    if action.startswith('observe:') and state.phase == 'forecast':
        if state.observed:
            return state
        attr = action.split(':')[1]
        index = {'wind': 1, 'cloud': 2}[attr]
        value = level.cards[state.secrets[state.day]][index]
        return replace(state, observed=attr, value=value,
                       candidates=candidates_for(level, attr, value))
    if action.startswith('toggle:') and state.phase == 'forecast':
        weather = int(action.split(':')[1])
        if weather in range(3):
            return replace(state, forecast=state.forecast ^ (1 << weather))
    if action == 'submit' and state.phase == 'forecast':
        if not state.candidates:
            raise ValueError('candidate table inconsistent')
        if state.forecast == 0:
            return state
        card = state.secrets[state.day]
        actual = level.cards[card][0]
        expected = estimate(level, state)
        report = Report(state.day, state.observed, state.value, state.candidates,
                        state.forecast, actual, card, payoff(state.forecast, actual),
                        expected['numerator'], expected['denominator'])
        return replace(state, phase='result', result=report, reports=state.reports+(report,))
    if action == 'next' and state.phase == 'result' and state.day < 2:
        return State(state.secrets, day=state.day+1, reports=state.reports)
    return state


def public_view(level, state):
    # JSON記録も画面と同じ公開情報だけ。次の日の秘密は記録しない。
    return {'day': state.day, 'phase': state.phase, 'observed': state.observed,
            'value': state.value, 'candidates': state.candidates,
            'forecast': state.forecast, 'counts': counts_for(level, state.candidates),
            'result': asdict(state.result) if state.result else None,
            'first': [asdict(r) for r in state.reports], 'practice': [],
            'total': sum(r.score for r in state.reports), 'complete': len(state.reports)==3,
            'replay': False, 'result_practice': False}

class Controller:
    def __init__(self):
        self.state = new()
        self.focus = 0
        self.previous = set()
        self.gate = False
        self.notice = ''
        self.explain = False
        self.assisted_days = set()
        self.assistance_records = []

    def mark_assistance(self):
        if self.explain and self.state.phase == 'forecast':
            self.assisted_days.add(self.state.day)

    def handle(self, key):
        if key == 'TAB':
            self.explain = not self.explain
            self.mark_assistance()
            return 'explain'
        if key in ('LEFT', 'RIGHT', 'UP', 'DOWN') and self.state.phase == 'forecast':
            self.focus = (self.focus + (-1 if key in ('LEFT', 'UP') else 1)) % 5
            return 'select'
        action = {'RETURN': 'submit', 'SPACE': 'next'}.get(key)
        if key == 'Z' and self.state.phase == 'forecast':
            action = ('observe:wind', 'observe:cloud', 'toggle:0', 'toggle:1', 'toggle:2')[self.focus]
        if action:
            self.mark_assistance()
            before = self.state
            self.state = act(LEVEL, self.state, action)
            if action == 'submit' and before.phase == 'forecast' and self.state.phase == 'result':
                self.assistance_records.append({'day': self.state.day, 'kind': 'first',
                                                'comparison_seen': self.state.day in self.assisted_days})
            self.mark_assistance()
            if self.state == before:
                self.notice = 'IGNORED / CHECK PHASE OR EMPTY FORECAST'
            return action if self.state != before else 'ignored:'+action

    def snapshot(self):
        return dict(public_view(LEVEL, self.state), focus=self.focus, notice=self.notice,
                    explain=self.explain, assisted=self.state.day in self.assisted_days,
                    assistance_records=list(self.assistance_records),
                    comparison=comparison_rows(LEVEL, self.state) if self.explain else [])

    def feed(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.notice = 'ONE KEY AT A TIME'
            return 'reject:chord'
        if not fresh:
            return None
        self.gate = True
        self.notice = ''
        return self.handle(next(iter(fresh)))
def mask_name(mask):
    return ''.join(letter if mask & (1 << i) else '-' for i, letter in enumerate('SOR'))


def draw_board(pyxel, controller):
    pyxel.cls(0)
    pyxel.text(16, 13, f'FORECASTER DESK / {STAGE:02} '+STAGE_TITLE, 10)
    pyxel.text(16, 30, 'Fictional tabletop model. Not real weather or financial forecasting.', 6)
    p = controller.snapshot()
    draw_bank(pyxel, p['candidates'])
    pyxel.text(342, 48, f'DAY {p["day"]+1}/3  '+p['phase'].upper(), 11)
    for i, label in enumerate(('WIND', 'CLOUD', 'SUN', 'OVERCAST', 'RAIN')):
        selected = i < 2 or bool(p['forecast'] & (1 << (i-2)))
        text = ('> ' if p['focus']==i else '  ')+label
        if i >= 2:
            text += ' [ON]' if selected else ' [OFF]'
        pyxel.text(342, 69+i*17, text, 10 if p['focus']==i else 7 if selected else 5)
    observed = p['observed']
    value = 'UNKNOWN' if not observed else (WIND if observed=='wind' else CLOUD)[p['value']]
    pyxel.text(342, 161, 'OBS: '+(observed.upper() or 'NONE')+' '+value, 11)
    pyxel.text(342, 179, 'MASK: '+mask_name(p['forecast'])+' / '+str(p['forecast']), 7)
    pyxel.text(342, 197, 'EMPTY CANNOT SUBMIT' if not p['forecast'] else f"1: +6/{p.get('penalty', -4)}   2: +2/-2   3: +1", 8 if not p['forecast'] else 7)
    pyxel.text(342, 217, 'FIRST TOTAL: '+str(p['total'])+' / PRACTICE: '+str(len(p['practice'])), 11)
    if p['result']:
        r = p['result']
        pyxel.text(342, 239, 'REVEALED: '+WEATHER[r['actual']]+f' / SCORE {r["score"]:+}', 10)
    else:
        pyxel.text(342, 239, 'ANSWER KNOWN: REPLAY' if p['replay'] else 'TODAY / FUTURE ANSWERS HIDDEN', 8 if p['replay'] else 6)
    if p['result']:
        card = LEVEL.cards[p['result']['card']]
        pyxel.text(342, 257, f"CARD #{p['result']['card']+1} / "+WIND[card[1]]+' / '+CLOUD[card[2]], 7)
    pyxel.text(342, 277, 'COMPLETE: ANY SCORE IS VALID' if p['complete'] else 'Submit all three days at any score.', 11)
    pyxel.text(342, 295, 'PRACTICE RESULT' if p['result_practice'] else 'Observation optional. No timer penalty.', 8 if p['result_practice'] else 6)
    pyxel.text(16, 332, 'ARROWS: select  Z: observe/toggle  ENTER: submit  SPACE: next  ESC: quit', 7)
    pyxel.text(16, 350, p['notice'] or 'Release all action keys before the next action.', 10)
    pyxel.text(16, 370, 'S/O/R = SUN / OVERCAST / RAIN. Forecast is a set, not a numeric interval.', 6)
    if p['explain']:
        pyxel.rect(332, 64, 294, 243, 1)
        pyxel.text(342, 73, 'PUBLIC CANDIDATES / EXACT EXPECTATION', 10)
        for i, row in enumerate(p['comparison']):
            pyxel.text(342, 99+i*22,
                       f'{mask_name(row["mask"])}  HIT {row["hit"]}/{row["total"]}'
                       f'  E={row["numerator"]}/{row["denominator"]}'
                       + (' *' if row['best'] else ''), 11 if row['best'] else 7)
        pyxel.text(342, 270, '* = MAXIMUM / KEEP ALL TIES', 6)
        pyxel.text(342, 290, 'ASSISTED COMPARISON / NOT A FORECAST WIN', 8)
    pyxel.text(16, 390, 'TAB: exact comparison / help use is recorded, even after closing it.', 7)
    pyxel.text(16, 410, 'HELP SEEN TODAY: '+str(p['assisted'])+" / E is an average, not today's answer.", 8)


def main(headless=False, actions='', screenshot=None, report=None):
    # 引数検証が済むまでPyxelをimportしない。モデル試験は標準ライブラリのみ。
    import json
    import hashlib
    from pathlib import Path
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel
    controller = Controller()
    codes = {name: getattr(pyxel, 'KEY_'+name) for name in KEYS}
    frame = 0
    events = []
    last_frame = max(12, len(actions)*2+2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame-1)//2
            down = {REPLAY[actions[index]]} if frame%2 and index<len(actions) else set()
        else:
            down = {name for name, code in codes.items() if pyxel.btn(code)}
        event = controller.feed(down)
        if event:
            events.append({'frame': frame, 'event': event, 'public': controller.snapshot()})

    def draw():
        draw_board(pyxel, controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless; synthetic Controller.feed inputs; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                    'events': events, 'snapshot': controller.snapshot(),
                }, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(640, 440, title=f'Forecaster Desk - Step {STAGE}', fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic keys: '+str(REPLAY))
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    actions = args.headless_replay or ''
    if any(action not in REPLAY for action in actions):
        parser.error('Unsupported replay character')
    main(headless, actions, args.screenshot, args.report)
STEP 05 / 05

初回提出と答えを知った再演を分離する

書きかえない状態と履歴・知ったことは消さない・版ごとの記録・入力の優先順位。

完成の段階では、元のゲームの状況 C の State・Report・Timeline と状態の変化をそのまま使い、X の Undo、R で同じ3日をやり直す、B の比較版を加えます。past は操作する前の状態ですが、restore は古い状態にただ戻すだけではありません。known(観測した項目)、revealed(答えを見た日)、first(初めての記録)、practice(やり直しの記録)は、今の知識から引きつぎます。観測を Undo しても、別の項目を追加で読むことはできません。結果を知った日に戻ると replay=True になり、次の提出は練習の欄に入って、初めての合計は変わりません。B は「1種類の予報が外れたとき」だけを -4 から -1 に変えます。初めての記録と練習の記録は版ごとに分け、観測と、答えを見た日は、版をまたいで共有します。元のゲームと同じく、初めての記録が0件でも、練習で3日報告すれば、その版の仕事は完了です。

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

05_honest_replay.py / 注目する部分
def replay(s):
    return s.day in s.revealed

実行して確かめる

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

Windows
.venv\Scripts\python.exe 05_honest_replay.py
macOS / Linux
.venv/bin/python 05_honest_replay.py
画面で見ることZ で表紙を閉じ、右 → Z で雲を観測して Enter で提出し、X で戻すと、ANSWER KNOWN: REPLAY(答えを知っているのでやり直し)になります。初めての記録は1件のままです。もう一度提出すると PRACTICE RESULT(練習の結果)になり、練習が1件増えます。B で別の版に移っても、すでに観測した項目と、答えを見たことは残ります。厚い雲の比較版では、1種類の期待点が (6-1-1)/3=4/3 になり、晴れ・くもり・雨の3つが同点でいちばんです。これは「外れたときの点」のルールを変えたことによる差で、予報の力が上がったわけではありません。
  • 知ったことは、Undo・やり直し・比較版でも消さない
  • first と practice を分けて、初めての合計を固定する
  • 手助けを使ったことは、版を変えても覚えておく
  • まだ答えを見ていない次の日を、見せる表示や記録に渡さない
  • 全部のキーを離してから、次を入力する
つまずきやすいところUndo や R で first を空にすると、答えを知ったあとの提出が、初めての成績にまざってしまいます。B でもひみつのカードの並びは同じなので、すでに知っている答えを消してはいけません。アプリを閉じたときに記録が消えることと、人が答えを忘れることは、別のことです。ここでいう「初めて」は、「このアプリを動かしている間で、まだ答えを見ていない日の最初の提出」という、プログラムの上での分け方です。教材・デモ・テストの結果を読んだ人が、自分の力で解いたことや、予報の力があることを証明するものではありません。
少し変えてみよう

Z → 右 → Z → Enter → X → Enter と押して、初めての記録1件と練習1件に分かれ、R でも両方が残ることを確かめましょう。次に B に移って、すでに知っている日の提出が練習になることを確かめてください。新しく起動して、観測なし・全部の予報で3日提出し、B に切りかえて、同じ3日を全部の予報で提出すると、その版は「初めて0件・練習3件・初めての合計0」で完了します。複数のキーの同時押しは全部受け付けず、片方だけ離しても受け付けないことも試します。

05_honest_replay.pyをダウンロードこの段階だけで実行できます
このステップのコード全文(505行)
05_honest_replay.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.
# 教材はASCIIと図形のみ。Noto字形データは含みません。

"""予報士の机・教材5/5。LESSON.mdを参照。単独起動可能、画面はASCII。"""
from dataclasses import dataclass, replace, asdict
import random

@dataclass(frozen=True)
class Level:
    id: str
    title: str
    seed: int
    cards: tuple

# 原作Cをそのまま採用。各行が等確率の1枚。重複をsetにしない。
LEVEL = Level('C', '雲だけでは決まらない日', 757,
              ((0, 0, 0), (0, 0, 1), (0, 0, 0), (1, 0, 1),
               (1, 1, 0), (2, 1, 0), (2, 1, 1), (2, 1, 0)))
WEATHER = ('SUN', 'OVERCAST', 'RAIN')
WIND = ('CALM', 'STRONG')
CLOUD = ('THIN', 'THICK')


def candidates_for(level, observed='', value=-1):
    if not observed:
        return tuple(range(len(level.cards)))
    index = {'wind': 1, 'cloud': 2}[observed]
    return tuple(i for i, card in enumerate(level.cards) if card[index] == value)


def counts_for(level, candidates):
    return tuple(sum(level.cards[i][0] == w for i in candidates) for w in range(3))


def draw_bank(pyxel, candidates, focus=None):
    # ここへ秘密の今日のカードを渡さない。focusは01の参照カーソルのみ。
    pyxel.text(16, 48, 'PUBLIC BANK / 8 EQUALLY WEIGHTED CARDS', 10)
    for i, (weather, wind, cloud) in enumerate(LEVEL.cards):
        x = 16 + (i % 2) * 154
        y = 69 + (i // 2) * 56
        active = i in candidates
        color = 7 if active else 5
        pyxel.rect(x, y, 145, 47, 1)
        pyxel.rectb(x, y, 145, 47, 11 if active else 5)
        if i == focus:
            pyxel.rectb(x-2, y-2, 149, 51, 10)
        pyxel.text(x+8, y+8, f'#{i+1} '+WEATHER[weather], color)
        pyxel.text(x+8, y+21, 'WIND: '+WIND[wind], color)
        pyxel.text(x+8, y+34, 'CLOUD: '+CLOUD[cloud], color)
    counts = counts_for(LEVEL, candidates)
    pyxel.text(16, 307, f'REMAIN: {len(candidates)}   SUN {counts[0]} / OVER {counts[1]} / RAIN {counts[2]}', 11)

STAGE = 5
STAGE_TITLE = 'HONEST REPLAY'
KEYS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'RETURN', 'SPACE', 'TAB', 'X', 'R', 'B', 'M', 'H', 'P')
REPLAY = {'L': 'LEFT', 'T': 'RIGHT', 'U': 'UP', 'D': 'DOWN', 'Z': 'Z', 'E': 'RETURN', 'S': 'SPACE', 'A': 'TAB', 'X': 'X', 'R': 'R', 'B': 'B', 'M': 'M', 'H': 'H', 'P': 'P'}

from fractions import Fraction

@dataclass(frozen=True)
class Report:
    day: int
    observed: str
    value: int
    candidates: tuple
    forecast: int
    actual: int
    card: int
    expected_num: int
    expected_den: int
    score: int
    mode: str

@dataclass(frozen=True)
class State:
    secrets: tuple
    known: tuple = ('', '', '')
    revealed: tuple = ()
    day: int = 0
    phase: str = 'forecast'
    observed: str = ''
    value: int = -1
    candidates: tuple = tuple(range(8))
    forecast: int = 7
    first: tuple = ()
    practice: tuple = ()
    result: object = None
    result_practice: bool = False
    reason: str = '風か雲を一つ観測できます。観測せず提出も可能。'

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

# 秘密の列は開始時に決める。再演では抽選し直さない。
def new(level):
    rng = random.Random(level.seed)
    return Timeline(State(tuple((rng.randrange(8) for _ in range(3)))))

def replay(s):
    return s.day in s.revealed

def penalty(mode):
    return -1 if mode == 'compare' else -4

def payoff(mask, actual, mode='base'):
    n = mask.bit_count()
    hit = bool(mask >> actual & 1)
    if n == 1:
        return 6 if hit else penalty(mode)
    if n == 2:
        return 2 if hit else -2
    if n == 3:
        return 1
    raise ValueError('empty forecast')

def estimate(level, s, mask=None, mode='base'):
    mask = s.forecast if mask is None else mask
    total = len(s.candidates)
    if not total:
        raise ValueError('candidate table inconsistent')
    if not mask:
        return None
    hit = sum((bool(mask >> level.cards[i][0] & 1) for i in s.candidates))
    expectation = sum((Fraction(payoff(mask, level.cards[i][0], mode), total) for i in s.candidates))
    return {'hit': hit, 'total': total,
            'numerator': expectation.numerator, 'denominator': expectation.denominator,
            'hit_score': 6 if mask.bit_count() == 1 else 2 if mask.bit_count() == 2 else 1,
            'miss_score': penalty(mode) if mask.bit_count() == 1
                          else -2 if mask.bit_count() == 2 else 1}

# 過去へ戻っても、既に知った属性から候補を再構成する。
def hydrate(level, s):
    attr = s.known[s.day]
    if attr and s.phase == 'forecast':
        idx = 1 if attr == 'wind' else 2
        value = level.cards[s.secrets[s.day]][idx]
        return replace(s, observed=attr, value=value, candidates=tuple((i for i, c in enumerate(level.cards) if c[idx] == value)))
    return s

# 操作履歴と知識の履歴は別。観測・開示・報告の知識は巻き戻さない。
def restore(level, old, current):
    reason = ('同じ日の再演。初回は残します。観測した知識も戻りません。'
              if old.day in current.revealed
              else '操作を戻しました。既に観測した情報はその日の材料に残ります。')
    remembered = replace(old, known=current.known, revealed=current.revealed,
                         first=current.first, practice=current.practice, reason=reason)
    return hydrate(level, remembered)

def act(level, tl, action, mode='base'):
    s = tl.state
    if action == 'undo':
        if not tl.past:
            return tl
        return Timeline(restore(level, tl.past[-1], s), tl.past[:-1], tl.actions + ('undo',))
    if action == 'restart':
        fresh = hydrate(level, State(s.secrets, known=s.known, revealed=s.revealed,
                                     first=s.first, practice=s.practice,
                                     reason='同じ3日を再開。既知の観測を保持し、開示済みの日は再演。'))
        return Timeline(fresh, tl.past + (s,), tl.actions + (action,))
    ns = s
    if action in ('observe:wind', 'observe:cloud') and s.phase == 'forecast':
        if s.observed:
            return replace(tl, state=replace(s, reason='観測は一日一種類。もう一つは結果で確認できます。'))
        attr = action.split(':')[1]
        idx = 1 if attr == 'wind' else 2
        v = level.cards[s.secrets[s.day]][idx]
        c = tuple((i for i, card in enumerate(level.cards) if card[idx] == v))
        if not c:
            raise ValueError('No candidate retains the actual card')
        known = list(s.known)
        known[s.day] = attr
        ns = replace(s, known=tuple(known), observed=attr, value=v, candidates=c, reason='観測と一致するカードを、重複も一枚ずつ残しました。')
    elif action.startswith('toggle:') and s.phase == 'forecast':
        w = int(action.split(':')[1])
        if w not in range(3):
            return tl
        ns = replace(s, forecast=s.forecast ^ 1 << w, reason='幅を選んでからEnterで提出。空の予報は提出できません。')
    elif action == 'submit' and s.phase == 'forecast':
        if not s.candidates:
            raise ValueError('candidate table inconsistent')
        if not s.forecast:
            return replace(tl, state=replace(s, reason='少なくとも一つの天気を残してください。'))
        e = estimate(level, s, mode=mode)
        card = s.secrets[s.day]
        actual = level.cards[card][0]
        report = Report(s.day, s.observed, s.value, s.candidates, s.forecast,
                        actual, card, e['numerator'], e['denominator'],
                        payoff(s.forecast, actual, mode), mode)
        # 既知の日はpracticeへ。初回を上書きして成功に見せない。
        ns = replace(s, phase='result', result=report, result_practice=replay(s),
                     revealed=tuple(sorted(set(s.revealed) | {s.day})),
                     first=s.first + (() if replay(s) else (report,)),
                     practice=s.practice + ((report,) if replay(s) else ()),
                     reason='提出を受理。結果を読んだらSpaceで次の日へ。' if s.day < 2
                            else '3日の仕事を完了。点数にかかわらず報告できました。')
    elif action == 'next' and s.phase == 'result' and (s.day < 2):
        ns = hydrate(level, State(s.secrets, known=s.known, revealed=s.revealed,
                                  day=s.day + 1, first=s.first, practice=s.practice,
                                  reason='次の日。未観測なら全8枚、既知の観測があれば保持します。'))
    else:
        return tl
    return Timeline(ns, tl.past + (s,), tl.actions + (action,))

# 描画・実行JSONの入り口。未開示のsecretsを絶対に返さない。
def public_view(level, s, mode='base'):
    counts = tuple((sum((level.cards[i][0] == w for i in s.candidates)) for w in range(3)))
    result = asdict(s.result) if s.phase == 'result' and s.result else None
    actual_card = level.cards[s.result.card] if result else None
    return {'day': s.day, 'phase': s.phase, 'observed': s.observed, 'value': s.value,
            'candidates': s.candidates, 'forecast': s.forecast, 'counts': counts,
            'estimate': estimate(level, s, mode=mode),
            'replay': replay(s) and s.phase == 'forecast',
            'result': result, 'result_practice': s.result_practice, 'actual_card': actual_card,
            'first': [asdict(r) for r in s.first],
            'practice': [asdict(r) for r in s.practice],
            'total': sum(r.score for r in s.first),
            'complete': len({r.day for r in s.first + s.practice}) == 3,
            'reason': s.reason, 'penalty': penalty(mode)}

def comparison_rows(level, state, mode='base'):
    # 補助計算。全て公開候補から計算し、秘密カードを参照しない。
    values = [(mask, estimate(level, state, mask, mode)) for mask in range(1, 8)]
    best = max(Fraction(e['numerator'], e['denominator']) for _, e in values)
    return [dict(mask=mask, **e, best=Fraction(e['numerator'], e['denominator'])==best)
            for mask, e in values]

# 原作controllerのCだけを残す。1/2/3面選択とS/F12を省略。
# 追加した支援記録は本体Reportとは別。ゲームの点数/完了条件を変えない。
class Controller:
    def __init__(self):
        self.mode = 'base'
        self.title = True
        self.previous = set()
        self.gate = False
        self.books = {}
        self.assisted_days = set()
        self.assistance_records = []
        self.reset()

    @property
    def state(self):
        return self.timeline.state

    def shared(self):
        same = [t.state for t in self.books.values()]
        known = tuple(next((s.known[d] for s in same if s.known[d]), '') for d in range(3))
        revealed = tuple(sorted({d for s in same for d in s.revealed}))
        return known, revealed

    def store(self):
        self.books[self.mode] = self.timeline

    def reset(self):
        self.timeline = self.books.get(self.mode, new(LEVEL))
        known, revealed = self.shared()
        state = replace(self.timeline.state, known=known, revealed=revealed)
        self.timeline = replace(self.timeline, state=hydrate(LEVEL, state))
        self.focus = 0
        self.explain = False
        self.help = False
        self.notice = ''

    def mark_assistance(self):
        if self.explain and not self.title and not self.help and self.state.phase=='forecast':
            self.assisted_days.add(self.state.day)

    def commit(self, action):
        old = self.timeline
        self.mark_assistance()
        self.timeline = act(LEVEL, old, action, self.mode)
        if action=='submit' and not old.state.forecast:
            self.notice = 'EMPTY FORECAST: KEEP AT LEAST ONE WEATHER'
        elif action.startswith('observe:') and old.state.observed:
            self.notice = 'ONE OBSERVATION PER DAY / KNOWLEDGE IS KEPT'
        if action=='submit' and old.state.phase=='forecast' and self.state.phase=='result':
            self.assistance_records.append({'day': self.state.day, 'mode': self.mode,
                'kind': 'practice' if self.state.result_practice else 'first',
                'comparison_seen': self.state.day in self.assisted_days})
        self.store()
        self.mark_assistance()
        return action if self.timeline != old else 'ignored:'+action

    def handle(self, key):
        if key=='M':
            self.store()
            self.title = True
            self.help = False
            return 'menu'
        if key=='B':
            self.store()
            self.mode = 'compare' if self.mode=='base' else 'base'
            self.reset()
            return 'mode'
        if key=='R':
            return self.commit('restart')
        if key in ('H','P'):
            self.help = not self.help
            self.mark_assistance()
            return 'help'
        if self.help:
            return 'ignored:help'
        if self.title:
            if key in ('SPACE','RETURN','Z'):
                self.title = False
                self.mark_assistance()
                return 'start_title'
            return 'ignored:title'
        if key=='X':
            return self.commit('undo')
        if key=='TAB':
            self.explain = not self.explain
            self.mark_assistance()
            return 'explain'
        if key=='SPACE':
            return self.commit('next')
        if key=='RETURN':
            return self.commit('submit')
        if self.state.phase!='forecast':
            return 'ignored:result'
        if key in ('LEFT','RIGHT','UP','DOWN'):
            self.focus = (self.focus+(-1 if key in ('LEFT','UP') else 1)) % 5
            return 'select'
        if key=='Z':
            return self.commit(('observe:wind','observe:cloud','toggle:0','toggle:1','toggle:2')[self.focus])

    def snapshot(self):
        p = public_view(LEVEL, self.state, self.mode)
        p.update(focus=self.focus, explain=self.explain, help=self.help,
                 title=self.title, mode=self.mode, notice=self.notice,
                 assisted=self.state.day in self.assisted_days,
                 assistance_records=list(self.assistance_records),
                 comparison=comparison_rows(LEVEL, self.state, self.mode) if self.explain else [])
        return p

    def feed(self, down):
        down = set(down) & set(KEYS)
        fresh = down - self.previous
        self.previous = down
        if self.gate:
            if not down:
                self.gate = False
            return None
        if len(fresh) > 1:
            self.gate = True
            self.notice = 'ONE KEY AT A TIME'
            return 'reject:chord'
        if not fresh:
            return None
        self.gate = True
        self.notice = ''
        return self.handle(next(iter(fresh)))
def mask_name(mask):
    return ''.join(letter if mask & (1 << i) else '-' for i, letter in enumerate('SOR'))


def draw_board(pyxel, controller):
    pyxel.cls(0)
    pyxel.text(16, 13, f'FORECASTER DESK / {STAGE:02} '+STAGE_TITLE, 10)
    pyxel.text(16, 30, 'Fictional tabletop model. Not real weather or financial forecasting.', 6)
    p = controller.snapshot()
    draw_bank(pyxel, p['candidates'])
    pyxel.text(342, 48, f'DAY {p["day"]+1}/3  '+p['phase'].upper(), 11)
    for i, label in enumerate(('WIND', 'CLOUD', 'SUN', 'OVERCAST', 'RAIN')):
        selected = i < 2 or bool(p['forecast'] & (1 << (i-2)))
        text = ('> ' if p['focus']==i else '  ')+label
        if i >= 2:
            text += ' [ON]' if selected else ' [OFF]'
        pyxel.text(342, 69+i*17, text, 10 if p['focus']==i else 7 if selected else 5)
    observed = p['observed']
    value = 'UNKNOWN' if not observed else (WIND if observed=='wind' else CLOUD)[p['value']]
    pyxel.text(342, 161, 'OBS: '+(observed.upper() or 'NONE')+' '+value, 11)
    pyxel.text(342, 179, 'MASK: '+mask_name(p['forecast'])+' / '+str(p['forecast']), 7)
    pyxel.text(342, 197, 'EMPTY CANNOT SUBMIT' if not p['forecast'] else f"1: +6/{p.get('penalty', -4)}   2: +2/-2   3: +1", 8 if not p['forecast'] else 7)
    pyxel.text(342, 217, 'FIRST TOTAL: '+str(p['total'])+' / PRACTICE: '+str(len(p['practice'])), 11)
    if p['result']:
        r = p['result']
        pyxel.text(342, 239, 'REVEALED: '+WEATHER[r['actual']]+f' / SCORE {r["score"]:+}', 10)
    else:
        pyxel.text(342, 239, 'ANSWER KNOWN: REPLAY' if p['replay'] else 'TODAY / FUTURE ANSWERS HIDDEN', 8 if p['replay'] else 6)
    if p['result']:
        card = LEVEL.cards[p['result']['card']]
        pyxel.text(342, 257, f"CARD #{p['result']['card']+1} / "+WIND[card[1]]+' / '+CLOUD[card[2]], 7)
    pyxel.text(342, 277, 'COMPLETE: ANY SCORE IS VALID' if p['complete'] else 'Submit all three days at any score.', 11)
    pyxel.text(342, 295, 'PRACTICE RESULT' if p['result_practice'] else 'Observation optional. No timer penalty.', 8 if p['result_practice'] else 6)
    pyxel.text(16, 332, 'ARROWS: select  Z: observe/toggle  ENTER: submit  SPACE: next  ESC: quit', 7)
    pyxel.text(16, 350, p['notice'] or 'Release all action keys before the next action.', 10)
    pyxel.text(16, 370, 'S/O/R = SUN / OVERCAST / RAIN. Forecast is a set, not a numeric interval.', 6)
    if p['explain']:
        pyxel.rect(332, 64, 294, 243, 1)
        pyxel.text(342, 73, 'EXACT E / '+p['mode'].upper(), 10)
        for i, row in enumerate(p['comparison']):
            pyxel.text(342, 99+i*22,
                       f'{mask_name(row["mask"])} HIT {row["hit"]}/{row["total"]}'
                       f' E={row["numerator"]}/{row["denominator"]}'
                       + (' *' if row['best'] else ''), 11 if row['best'] else 7)
        pyxel.text(342, 272, '* ALL TIED MAXIMA / PUBLIC DATA ONLY', 6)
        pyxel.text(342, 290, 'ASSISTED COMPARISON', 8)
    pyxel.text(16, 390, 'X: undo  R: same 3 days  B: compare mode  TAB: exact E  H/P: help  M: title', 7)
    pyxel.text(16, 410, 'MODE: '+p['mode'].upper()+' / SINGLE MISS '+str(p['penalty'])+
               ' / HELP SEEN TODAY: '+str(p['assisted']), 8)
    if p['title'] or p['help']:
        pyxel.rect(38, 64, 564, 258, 1)
        pyxel.rectb(38, 64, 564, 258, 10)
        lines = ['FORECASTER DESK / ORIGINAL CASE C',
                 '8 public cards. Each card is one equally likely possibility.',
                 'Observe wind OR cloud; then choose any nonempty weather set.',
                 'Base: one +6/-4; two +2/-2; all +1. Any 3 reports complete.',
                 'No observation, low score and non-optimal forecasts are valid.',
                 'B: only the single-weather miss changes to -1.',
                 'X / R / B keep learned facts. Known answers are REPLAY.',
                 'TAB shows an assisted exact comparison. All ties remain tied.',
                 'Only FIRST records count in the total; practice is separate.',
                 '3 days cannot prove real predictive skill. Fictional model.',
                 'Z / ENTER / SPACE: start' if p['title'] else 'H / P: close help']
        for i, line in enumerate(lines):
            pyxel.text(54, 80+i*21, line, 10 if i in (0,10) else 7)


def main(headless=False, actions='', screenshot=None, report=None):
    # 引数検証が済むまでPyxelをimportしない。モデル試験は標準ライブラリのみ。
    import json
    import hashlib
    from pathlib import Path
    screenshot = str(Path(screenshot).resolve()) if screenshot else None
    report = str(Path(report).resolve()) if report else None
    import pyxel
    controller = Controller()
    codes = {name: getattr(pyxel, 'KEY_'+name) for name in KEYS}
    frame = 0
    events = []
    last_frame = max(12, len(actions)*2+2)

    def update():
        nonlocal frame
        frame += 1
        if headless:
            index = (frame-1)//2
            down = {REPLAY[actions[index]]} if frame%2 and index<len(actions) else set()
        else:
            down = {name for name, code in codes.items() if pyxel.btn(code)}
        event = controller.feed(down)
        if event:
            events.append({'frame': frame, 'event': event, 'public': controller.snapshot()})

    def draw():
        draw_board(pyxel, controller)
        if headless and frame >= last_frame:
            if screenshot:
                pyxel.screenshot(screenshot, scale=1)
            if report:
                Path(report).write_text(json.dumps({
                    'label': 'Actual Pyxel headless; synthetic Controller.feed inputs; silent',
                    'stage': STAGE, 'frames': frame, 'actions': actions,
                    'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                    'events': events, 'snapshot': controller.snapshot(),
                }, ensure_ascii=False, indent=2)+'\n', encoding='utf-8')
            pyxel.quit()

    pyxel.init(640, 440, title=f'Forecaster Desk - Step {STAGE}', fps=30, headless=headless)
    pyxel.run(update, draw)


if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--headless-smoke', action='store_true')
    parser.add_argument('--headless-replay', help='Synthetic keys: '+str(REPLAY))
    parser.add_argument('--screenshot', help='PNG output; headless only')
    parser.add_argument('--report', help='UTF-8 JSON output; headless only')
    args = parser.parse_args()
    if args.headless_smoke and args.headless_replay is not None:
        parser.error('Choose smoke or replay')
    headless = args.headless_smoke or args.headless_replay is not None
    if not headless and (args.screenshot or args.report):
        parser.error('Outputs require headless mode')
    actions = args.headless_replay or ''
    if any(action not in REPLAY for action in actions):
        parser.error('Unsupported replay character')
    main(headless, actions, args.screenshot, args.report)
AFTER STEP 05

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

元のゲームの状況 C(LEVELS[2])、seed 757、8枚のカードから「戻して引く」方法で3日分。観測できるのは風か雲の1種類までです。空でない7つの予報をすべて受け付けます。1種類は +6/-4、2種類は +2/-2、全部は +1。点数に関係なく、3日報告すれば完了です。比較版は「1種類の予報が外れたとき」だけ -1 です。知っている観測と答えを見た日は、Undo・やり直し・版の変更でも残します。

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

比べる点5ステップの教材元の完成版
問題の範囲状況 C「雲だけでは決まらない日」だけです。05 の中身と、対象のキーの操作は、元のゲームの状況 C と直接照らし合わせています。A・B・C の3つの状況があり、1/2/3 で選びます。
段階構成01 は表を見るだけ、02 は1日目の観測だけ、03 はやり直しのない3日の提出、04 で期待点の表、05 で元のゲームのやり直しと版の変更です。01〜04 は、完成したゲームと同じではありません。最初から全部の中身・観測・幅・期待点・3日・履歴・比較版が使えます。
支援比較04〜05 の Tab は、7つすべての予報の期待点と、同点の最大を見せる教材用の手助けです。選択は変えません。その日に表を見たことは別に記録し、閉じても Undo・やり直し・版の変更でも残します。Tab は、今選んでいる予報の期待点だけを表示します。手助けを使ったかどうかは、専用の欄には記録しません。
表示・記録640×440、Pyxel 標準の文字(ASCII)と図形で、音はありません。S/F12、GIF の記録は省いています。画面を出さないコマンドで、実際の Pyxel の画像(PNG)と、見せてよい情報だけの UTF-8 の JSON を出力します。900×640、日本語の文字、天気のマーク、S/F12、全部の状態の記録、デモの GIF などがあります。
入力05 は、対象のキーについて、元のゲームの入力の優先順位と「全部離すまで待つ」処理を守っています。M/B/R は説明や表紙より優先し、H/P で説明を開き、説明中はふつうの操作を止めます。1/2/3 と S/F12 は対象外です。全部離すまで待ちます。新しく押したキーが複数あれば受け付けません。F12/S/M/1〜3/B/R/H/P の順に特別な処理をして、そのあと説明・表紙・ふつうの入力を処理します。
権利・検証範囲Noto の文字は入れていませんが、元のゲームの LICENSE と、参照している Noto/Pyxel の権利の文書はそのまま残しています。実際の Pyxel で画面を出さないテストと、作った入力による操作のテストで機能を確かめています。Noto をもとにした文字を使っています。元のゲームの19のテストやデモの証拠と、教材のテストは別に扱います。

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

  1. 元のゲームの状況 A・B を加えて、状況ごとの知識と、版ごとの記録を分けてテストしてみましょう。
  2. 候補の重みが同じではない模型を別の作品として作り、Fraction で重みの合計を1にそろえてみましょう。
  3. 元のゲームの日本語の文字と権利の表示を読み、対応するライセンスを守りながら、表示を広げてみましょう。
  4. 手助けなし・ありの記録を見えるようにしてみましょう。ただし、少ない実際の得点を、予報の力の証明にはしないでください。
  5. 元のゲームの tools/verify_policies.py を読み、限られた候補での期待値の比較と、3日間の実際の得点を区別してみましょう。
自分のゲームに変えるなら

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

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