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