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