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