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