# 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. # 教材にはNoto由来字形は含めません。描画はPyxel標準ASCIIと図形です。 """伝言は三文字・教材 5/5。原作Bを段階的に学ぶ独立プログラム。""" from dataclasses import dataclass, replace, asdict @dataclass(frozen=True) class Recipient: name: str errands: tuple meanings: tuple glosses: tuple @dataclass(frozen=True) class Level: id: str title: str letters: tuple people: tuple targets: tuple hints: tuple WORKSHOP = Recipient( '工房の係', ('灯を磨く / 東机', '灯を運ぶ / 西机', '鈴を磨く / 東机', '鈴を運ぶ / 西机'), ((0, 1), (0, 2), (0, 2), (), (0, 1, 2, 3), (), (1, 3), (2, 3)), ('灯の用事', '東机の印', '磨く用事', '該当なし', '全て机の用事', '該当なし', '運ぶ用事', '鈴の用事'), ) RECEPTION = Recipient( '受付の係', ('青い封を窓へ', '赤い封を机へ', '青い箱を机へ', '赤い箱を窓へ'), ((), (0, 2), (), (2, 3), (1, 2), (0, 3), (0, 1, 2, 3), ()), ('該当なし', '荷物が青色', '該当なし', '箱の用事', '机へ届ける', '窓へ届ける', '全て運ぶ用事', '該当なし'), ) LEVEL = Level('067-B-g1', '青の意味が違う', tuple('灯青磨箱机窓運鈴'), (WORKSHOP, RECEPTION), (0, 2), ('青は工房では東机、受付では色。', '送った文は、前の相手の意味で保存。')) LETTER_NAMES = ('LAMP', 'BLUE', 'POLISH', 'BOX', 'DESK', 'WINDOW', 'CARRY', 'BELL') PERSON_NAMES = ('WORKSHOP', 'RECEPTION') ERRAND_NAMES = ( ('Polish lamp / EAST desk', 'Carry lamp / WEST desk', 'Polish bell / EAST desk', 'Carry bell / WEST desk'), ('Blue envelope / WINDOW', 'Red envelope / DESK', 'Blue box / DESK', 'Red box / WINDOW'), ) GLOSS_NAMES = ( ('Lamp errands', 'EAST desk mark', 'Polish errands', 'No matching errand', 'All desk errands', 'No matching errand', 'Carry errands', 'Bell errands'), ('No matching errand', 'BLUE color', 'No matching errand', 'Box errands', 'To DESK', 'To WINDOW', 'All delivery errands', 'No matching errand'), ) def interpret(person, letters): """各札が指す集合の積集合。順番は問わない。空なら全候補。""" candidates = set(range(len(person.errands))) for letter in letters: candidates.intersection_update(person.meanings[letter]) return tuple(sorted(candidates)) STAGE = 5 STAGE_TITLE = 'TWO REQUESTS + UNDO' REQUEST_COUNT = 2 USE_UNDO = True ALLOW_CONFIRM = True CONTROLS = ('1', '2', '3', '4', '5', '6', '7', '8', 'R', 'LEFT', 'RIGHT', 'SPACE', 'RETURN', 'C', 'X') HELP_ONE = '1..8: add card LEFT/RIGHT: slot SPACE: erase ENTER: send/confirm' HELP_TWO = 'C: safe full reading (+2 rounds) X: undo R: reset ESC: quit' @dataclass(frozen=True) class Result: person: str target: int letters: tuple safe: bool rounds: int @dataclass(frozen=True) class State: request: int = 0 available: tuple = tuple(range(8)) draft: tuple = () sent: tuple = () candidates: tuple = (0, 1, 2, 3) posts: int = 0 rounds: int = 0 results: tuple = () phase: str = 'draft' tick: int = 0 def initial(): return State() def finish(state, safe): """全文確認は必ず正しい依頼を完了し、追加2往復。未送信の札は使わない。""" used_rounds = state.rounds + 2 * safe - sum(r.rounds for r in state.results) result = Result(LEVEL.people[state.request].name, LEVEL.targets[state.request], state.sent, safe, used_rounds) next_request = state.request + 1 return replace(state, request=next_request, draft=(), sent=(), posts=0, candidates=tuple(range(4)) if next_request < REQUEST_COUNT else (), results=state.results + (result,), rounds=state.rounds + 2 * safe, phase='draft' if next_request < REQUEST_COUNT else 'won', tick=state.tick + 1) def advance(state, action): """状態を直接書き換えず、新しいStateと理由を返す。""" if state.phase == 'won': return state, 'ended' op, separator, arg = action.partition(':') if op == 'place' and separator and arg.isdigit(): letter = int(arg) limit = 1 if state.phase == 'reply' else 3 if letter not in state.available or letter in state.draft: return state, 'unavailable' if len(state.draft) >= limit: return state, 'draft_full' if state.phase == 'reply' and (len(state.candidates) <= 1 or state.posts >= 2): return state, 'no_post' return replace(state, draft=state.draft + (letter,), tick=state.tick + 1), 'placed' if op == 'erase' and separator and arg.isdigit(): slot = int(arg) if slot >= len(state.draft): return state, 'bad_slot' draft = state.draft[:slot] + state.draft[slot + 1:] return replace(state, draft=draft, tick=state.tick + 1), 'erased' if action == 'send': if not state.draft: return state, 'empty' if state.phase == 'reply' and (len(state.candidates) <= 1 or state.posts >= 2 or len(state.draft) != 1): return state, 'no_post' sent = state.sent + state.draft candidates = interpret(LEVEL.people[state.request], sent) return replace(state, available=tuple(i for i in state.available if i not in state.draft), draft=(), sent=sent, candidates=candidates, posts=state.posts + (state.phase == 'reply'), rounds=state.rounds + 1, phase='reply', tick=state.tick + 1), 'reply' if action == 'confirm': if state.phase != 'reply' or len(state.candidates) != 1: return state, 'need_unique' if state.candidates[0] != LEVEL.targets[state.request]: return state, 'wrong_errand' return finish(state, False), 'completed' if action == 'safe': return finish(state, True), 'safe_completed' return state, 'unknown' @dataclass(frozen=True) class Timeline: state: State history: tuple = () def step(timeline, action): if action == 'undo': if not timeline.history: return timeline, 'no_undo' return Timeline(timeline.history[-1], timeline.history[:-1]), 'undo' state, reason = advance(timeline.state, action) if state == timeline.state: return timeline, reason # 拒否した操作は履歴を増やさない。 return Timeline(state, timeline.history + (timeline.state,)), reason class Game: def __init__(self): self.reset() @property def state(self): return self.timeline.state if USE_UNDO else self.current def reset(self): if USE_UNDO: self.timeline = Timeline(initial()) else: self.current = initial() self.reason = 'ready' def act(self, action): if USE_UNDO: self.timeline, self.reason = step(self.timeline, action) else: self.current, self.reason = advance(self.current, action) class Controller: """実画面もテストも同じ入口。1回押して離すと1判断。""" def __init__(self): self.game = Game() self.slot = 0 self.gate = False def tick(self, pressed=(), held=()): pressed, held = set(pressed), set(held) if self.gate: if not (held & set(CONTROLS)): self.gate = False return if len(pressed & set(CONTROLS)) != 1 or len(held & set(CONTROLS)) != 1: return # 同時押しを1つの判断に勝手に変換しない。 key = next(iter(pressed & set(CONTROLS))) self.gate = True if key == 'R': self.game.reset() self.slot = 0 elif key in '12345678': self.game.act('place:' + str(int(key) - 1)) elif key == 'LEFT': self.slot = (self.slot - 1) % 3 elif key == 'RIGHT': self.slot = (self.slot + 1) % 3 elif key == 'SPACE': self.game.act('erase:' + str(self.slot)) elif key == 'RETURN': state = self.game.state confirm = ALLOW_CONFIRM and state.phase == 'reply' and len(state.candidates) == 1 and not state.draft self.game.act('confirm' if confirm else 'send') elif key == 'C': self.game.act('safe') elif key == 'X': self.game.act('undo') def numbers(values): return ','.join(str(v + 1) for v in values) or '-' def names(values): return ' + '.join(LETTER_NAMES[v] for v in values) or '(none)' def main(headless=False, screenshot=None, actions='', report=None): import json from pathlib import Path # Pyxel初期化前に、出力先を呼び出した場所基準の絶対パスにする。 screenshot = str(Path(screenshot).resolve()) if screenshot else None report = str(Path(report).resolve()) if report else None import pyxel controller = Controller() key_codes = {k: getattr(pyxel, 'KEY_' + k) for k in CONTROLS} replay_keys = {'E': 'RETURN', 'D': 'SPACE', 'L': 'LEFT', 'T': 'RIGHT', 'C': 'C', 'X': 'X', 'R': 'R'} replay_keys.update({k: k for k in '12345678'}) frame = 0 events = [] frames = max(12, len(actions) * 2 + 2) def update(): nonlocal frame frame += 1 if headless: index = (frame - 1) // 2 if frame % 2 and index < len(actions): key = replay_keys[actions[index]] controller.tick((key,), (key,)) events.append({'input': actions[index], 'reason': controller.game.reason, 'state': asdict(controller.game.state)}) else: controller.tick((), ()) else: controller.tick([k for k, v in key_codes.items() if pyxel.btnp(v)], [k for k, v in key_codes.items() if pyxel.btn(v)]) def text(x, y, message, color=7): pyxel.text(x, y, message, color) def draw(): state = controller.game.state request = min(getattr(state, 'request', 0), REQUEST_COUNT - 1) person = LEVEL.people[request] available = getattr(state, 'available', tuple(range(8))) sent = getattr(state, 'sent', ()) phase = getattr(state, 'phase', 'preview') pyxel.cls(1) text(12, 10, 'THREE LETTERS / STEP ' + str(STAGE) + ' / ' + STAGE_TITLE, 10) text(12, 25, 'RULES: B - recipient-specific sets, no word order', 6) pyxel.rect(8, 39, 544, 49, 2) text(16, 46, 'CURRENT: ' + PERSON_NAMES[request] + ' REQUEST ' + str(request + 1) + '/' + str(REQUEST_COUNT), 11) text(16, 59, 'TARGET #' + str(LEVEL.targets[request] + 1) + ': ' + ERRAND_NAMES[request][LEVEL.targets[request]], 10) next_label = 'NEXT: RECEPTION #3 Blue box / DESK' if REQUEST_COUNT == 2 and request == 0 else 'NEXT: none in this lesson' text(16, 74, next_label, 6) text(12, 101, 'KEY', 7) text(32, 101, 'CARD', 7) text(83, 101, 'CURRENT MEANING', 7) text(232, 101, 'ERRAND IDS', 7) text(340, 101, 'ERRANDS / * = CANDIDATE', 7) for i in range(8): y = 117 + i * 19 used, drafted = i not in available, i in state.draft pyxel.rect(8, y - 3, 318, 17, 2 if not drafted else 3) color = 13 if used else 10 if drafted else 7 text(13, y, str(i + 1), color) text(32, y, LETTER_NAMES[i], color) text(83, y, GLOSS_NAMES[request][i], color) text(232, y, numbers(person.meanings[i]), color) if used: text(286, y, 'USED', 8) shown = state.candidates for i, label in enumerate(ERRAND_NAMES[request]): y = 121 + i * 28 if i in shown: pyxel.rect(336, y - 5, 216, 23, 3) text(341, y, ('*' if i in shown else ' ') + str(i + 1) + ' ' + label, 10 if i in shown else 6) text(340, 241, 'CANDIDATES: ' + numbers(shown), 11) text(340, 256, 'PHASE: ' + phase.upper(), 6) pyxel.rect(8, 276, 544, 60, 2) text(16, 284, 'DRAFT: ' + names(state.draft), 10) text(16, 299, 'SENT: ' + names(sent), 7) text(16, 316, 'AVAILABLE: ' + numbers(available) + ' ERASE SLOT: ' + str(controller.slot + 1), 6) rounds = getattr(state, 'rounds', 0) posts = getattr(state, 'posts', 0) status = 'SELECTED: ' + str(len(state.draft)) if STAGE <= 2 else 'ROUNDS: ' + str(rounds) if STAGE >= 4: status += ' POSTS: ' + str(posts) + '/2' text(12, 346, status + ' RESULT: ' + controller.game.reason.upper(), 11) text(12, 362, HELP_ONE, 7) text(12, 377, HELP_TWO, 6) if phase == 'won': text(12, 392, 'SUCCESS: correct request(s)! Safe full reading counts too.', 10) elif STAGE <= 2: text(12, 392, 'PREVIEW ONLY: no sending or consumption in this lesson.', 13) elif STAGE == 3: text(12, 392, 'Read reply, then R to retry. Confirm arrives in step 4.', 13) else: text(12, 392, 'C: read full request safely (+2 rounds, no extra cards)', 13) for index, result in enumerate(getattr(state, 'results', ())): recipient_index = LEVEL.people.index(next(p for p in LEVEL.people if p.name == result.person)) mode = 'SAFE' if result.safe else 'UNIQUE' text(12, 409 + index * 14, f'DONE {index + 1}: {PERSON_NAMES[recipient_index]} #{result.target + 1} {mode} / {result.rounds} rounds / ' + names(result.letters), 6) if headless and frame >= frames: if screenshot: pyxel.screenshot(screenshot, scale=1) if report: Path(report).write_text(json.dumps({ 'label': 'Actual Pyxel headless draw with synthetic controller keys; silent', 'stage': STAGE, 'frames': frame, 'actions': actions, 'events': events, 'state': asdict(state), 'history_length': len(controller.game.timeline.history) if USE_UNDO else 0, }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') pyxel.quit() pyxel.init(560, 444, title='Three Letters - Step ' + str(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: 1..8 E=Enter D=erase L/T=slot C=safe X=undo R=reset') 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('Output options require headless mode') actions = args.headless_replay or '' replay_allowed = set('12345678R') | ({'D', 'L', 'T'} if STAGE >= 2 else set()) | ({'E'} if STAGE >= 3 else set()) | ({'C'} if STAGE >= 4 else set()) | ({'X'} if STAGE == 5 else set()) if any(key not in replay_allowed for key in actions): parser.error('Unsupported key in this stage') main(headless, args.screenshot, actions, args.report)