完成サンプルをブラウザーで遊ぶ
この講座のもとになった完成版のゲームを、ブラウザーでそのまま遊べます。キーボードで操作するので、PCで開いてください(スマホのタッチ操作には対応していません)。初めて開くときは、読み込みに少し時間がかかります。
完成版の主な操作:矢印観測・天気/Z選択、Enter提出、Space翌日、Tab期待点、X戻す、R同じ三日
ボタンを押すと、ゲームの読み込みが始まります。
画面に「CLICK TO START」と出たらクリックします。続けてゲーム画面を一度クリックすると、キー操作が効くようになります。ゲームを終えるときは、枠の外にある「閉じる」ボタンを使ってください(Escキーの働きはゲームごとに違います)。ゲーム内のセーブは、閉じる・ページを移る・再読み込みすると消えます。
うまく表示されないとき
ゲームを動かすためのプログラム(Pyxel 2.9.9)は、外部の配信サービス(jsDelivr)から読み込みます。表示されないときは「最初から再読込」を押すか、完成版のソースをダウンロードしてPCで実行してください。ブラウザー版の録画機能は止めています。ブラウザー版で確認できている範囲は検証状況にまとめています。
候補の数から、正確な期待点を求める。
いきなり完成版のコードを全部読む必要はありません。小さなゲームにルールを1つずつ足しながら、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=終了、操作キーを全て離して次へ。同時押しは拒否']
- 難易度の目安
- 中級このページで使うプログラムの考え方の数と難しさから決めた目安です。難易度について
- 学習順に迷ったら
- おすすめの学ぶ順番を見る
コードを書き換えたら、ファイルを保存し、動いているゲームを閉じてから、もう一度同じ.pyを実行します。ゲーム中にRキーなどで「やり直し」をしても、書き換えたコードは読み込まれません。
教材のゲーム画面は、日本語フォントを用意しなくても動くように英語で表示しています。ファイルには動作確認用のコードも入っていますが、最初は Game / State・update・draw の3つに注目すれば十分です。わからない言葉は用語集で調べられます。class・self・dataclass はPythonの状態、キー入力と時間の扱いは入力・更新・描画、マス目とピクセルの違いは画面とマップの座標のページで詳しく説明しています。
公開カードを重複も含めて数える
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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
def counts_for(level, candidates):
return tuple(sum(level.cards[i][0] == w for i in candidates) for w in range(3))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 01_card_bank.py.venv/bin/python 01_card_bank.py- 8枚は同じ重みで、6種類とは区別する
- 画面の番号は1から、プログラムの番号は0から
- 候補の番号を保存し、カードを増やしたり消したりしない
- 見ているカーソルから、本当の今日を推測しない
cards[3] で読むと、#4 を見てしまいます。カーソルと、かくされた答えも別のものです。右 → 右 で #3 を見て、#1 と重なっていることを確かめます。R → 左 で #8 に一周することを確かめ、紙に「晴れ 3/8・くもり 2/8・雨 3/8」と書いて、合計が1になるか調べてください。8枚から重なりを消すと種類は6ですが、この6は確率の分母には使いません。
このステップのコード全文(196行)
# 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)一つの観測から候補集合を作る
かくれた状態と見えている情報・条件で絞りこむ・1日1種類の観測。
元のゲームと同じ random.Random(seed) で、3日分を「戻して引く」方法で選びます。「戻して引く」とは、昨日のカードを箱から取り除かず、毎回8枚から選ぶことです。この段階は1日目の観測だけを扱います。矢印で風か雲を選び、Z で1種類を観測します。observe はその項目の値だけを読み、candidates_for で、見えている8枚のうち同じ値を持つカードを残します。残ったカードは全部「ありうる答え」です。カードの番号・観測していない項目・本当の天気は、public_view(見せる情報)には渡しません。2種類目まで観測できると別の条件になってしまうので、一度観測した日はやり直せません。01 の R は見る位置を戻すだけでしたが、この段階では R を外して、観測した情報を消せるように見せないようにしています。
下のコードは 02_observation.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
return tuple(i for i, card in enumerate(level.cards) if card[index] == value)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 02_observation.py.venv/bin/python 02_observation.py- 観測できるのは、風か雲の1種類だけ
- 候補には、中身が重なるカードも残す
- 観測する前は分母8、観測したあとは残った枚数
- 見せる表示や JSON に、未来のひみつを渡さない
public_view に入れたりすると、問題を解く前に答えを渡してしまいます。右 → Z で厚い雲の3枚を数え、そのあと左 → Z で風も観測しようとしても、観測が cloud(雲)のままであることを確かめましょう。紙の表で、静かな風なら #1/#2/#3/#4、強い風なら #5/#6/#7/#8、うすい雲なら #1/#3/#5/#6/#8 と書き出してみてください。アプリを閉じても、あなたの記憶は消えません。
このステップのコード全文(227行)
# 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)予報の幅と採点を分けて実装する
ビットで表す集合・空にしない決まり・「予報」と「結果」の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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
return replace(state, forecast=state.forecast ^ (1 << weather))実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 03_forecast_width.py.venv/bin/python 03_forecast_width.py- 1/2/4 のビットで、晴れ・くもり・雨を別々に選ぶ
- 空(0)の予報だけを受け付けない
- 結果を読んでいる間に、勝手に次の日へ進まない
- 3日報告すれば完了。高得点や一番いい選び方は必要ない
右を2回 → Z で晴れを外し、右 → Z でくもりを外し、右 → Z で雨を外して、mask=0 にします。Enter が受け付けられないことを確かめ、同じ雨の欄で Z → Enter と押すと、「雨だけ」の正しい提出になることを確かめましょう。別に、「全部」の予報で3日提出すると合計+3になる手順も通してください。勝ち負けによる合格・不合格はありません。
このステップのコード全文(299行)
# 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)Fractionで全7予報の期待点を比べる
正確な分数・条件付きの平均・同点を残す比べ方・手助けの表示の記録。
Fraction(分数)を使って、候補のカードそれぞれでとれる点数を、候補の枚数で割って足します。整数から Fraction を作るので、小数の丸めで順位が入れかわることがありません。Tab で表示するのは、7つすべての予報について「当たる枚数」と「期待点」を見せる、教材用の手助けです。期待点は、同じ条件で何回もくり返したときの平均で、今日の点数や、決まった答えではありません。いちばん大きい値と同じ行には、全部 * を付け、同点のときに1つを勝手に選んだりしません。観測なしでは、「晴れ+雨」と「全部」の期待点がどちらも1で、同点です。厚い雲の3枚では、1種類が -2/3、2種類が 2/3、全部が1になります。手助けは表示するだけで、forecast は変えません。表を閉じても、その日に比べた表を見たことは、提出のときに assistance_records に残します。
下のコードは 04_expected_value.py の一部です。前のステップから追加・変更したところだけを抜き出しています。
expected = sum(Fraction(payoff(mask, level.cards[i][0], mode), total)
for i in state.candidates)実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 04_expected_value.py.venv/bin/python 04_expected_value.pyFraction(整数,整数)で正確に計算する- 「当たる確率」と「点数の期待値」を分ける
- 同点の最大は全部残し、いちばんではない選択も許す
- 手助けの表示を使って提出したことを保存する
まず、観測なしで 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 で確かめています。
このステップのコード全文(361行)
# 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)初回提出と答えを知った再演を分離する
書きかえない状態と履歴・知ったことは消さない・版ごとの記録・入力の優先順位。
完成の段階では、元のゲームの状況 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 の一部です。前のステップから追加・変更したところだけを抜き出しています。
def replay(s):
return s.day in s.revealed実行して確かめる
教材のZIPを展開したフォルダーで、次のコマンドを実行します。
.venv\Scripts\python.exe 05_honest_replay.py.venv/bin/python 05_honest_replay.py- 知ったことは、Undo・やり直し・比較版でも消さない
firstとpracticeを分けて、初めての合計を固定する- 手助けを使ったことは、版を変えても覚えておく
- まだ答えを見ていない次の日を、見せる表示や記録に渡さない
- 全部のキーを離してから、次を入力する
first を空にすると、答えを知ったあとの提出が、初めての成績にまざってしまいます。B でもひみつのカードの並びは同じなので、すでに知っている答えを消してはいけません。アプリを閉じたときに記録が消えることと、人が答えを忘れることは、別のことです。ここでいう「初めて」は、「このアプリを動かしている間で、まだ答えを見ていない日の最初の提出」という、プログラムの上での分け方です。教材・デモ・テストの結果を読んだ人が、自分の力で解いたことや、予報の力があることを証明するものではありません。Z → 右 → Z → Enter → X → Enter と押して、初めての記録1件と練習1件に分かれ、R でも両方が残ることを確かめましょう。次に B に移って、すでに知っている日の提出が練習になることを確かめてください。新しく起動して、観測なし・全部の予報で3日提出し、B に切りかえて、同じ3日を全部の予報で提出すると、その版は「初めて0件・練習3件・初めての合計0」で完了します。複数のキーの同時押しは全部受け付けず、片方だけ離しても受け付けないことも試します。
このステップのコード全文(505行)
# 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)仕組みがわかったら、元の完成版を読んでみよう。
元のゲームの状況 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のテストやデモの証拠と、教材のテストは別に扱います。 |
完成版のコードで、次に読むところ
- 元のゲームの状況 A・B を加えて、状況ごとの知識と、版ごとの記録を分けてテストしてみましょう。
- 候補の重みが同じではない模型を別の作品として作り、
Fractionで重みの合計を1にそろえてみましょう。 - 元のゲームの日本語の文字と権利の表示を読み、対応するライセンスを守りながら、表示を広げてみましょう。
- 手助けなし・ありの記録を見えるようにしてみましょう。ただし、少ない実際の得点を、予報の力の証明にはしないでください。
- 元のゲームの
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の実際のパソコンでの操作や、音が出るかどうかはまだ確認していません。人がすべての操作を試したわけでもありません。ブラウザー版の確認範囲は確認状況をご覧ください。
うまく動かないときは:起動やエラーの対処法 / 質問用のメモを作る
