# 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. """一夜の探検隊・教材 3/5。Python 3.11+ / Pyxel 2.9.9。""" from dataclasses import dataclass, replace, asdict NAMES = ('AKI', 'FUMI', 'RUU') SKILLS = ('C', 'D', 'W') LAND = {'D': 'DARK', 'C': 'CLIFF', 'W': 'COLD', 'P': 'PLAIN'} SCENERY = {'D': '夜の花', 'C': '町の灯り', 'W': '星と霜', 'P': '草原'} BASE = {'D': 2, 'C': 3, 'W': 2, 'P': 1} ROUTE = ('D', 'C', 'P', 'W', 'D', 'P') TOOLS = ('LANTERN', 'ROPE', 'BLANKET') TOOL = {'D': 0, 'C': 1, 'W': 2} @dataclass(frozen=True) class State: segment: int = 0 hp: tuple = (5, 5, 5) order: tuple = (0, 1, 2) owners: tuple = (0, 1, 2) path: tuple = () log: tuple = () ended: str = '' def new(): return State() def terrain(state, branch=0): return ROUTE[state.segment] def burden(state, branch=0): land = terrain(state, branch) leader = state.order[0] expert = int(SKILLS[leader] == land) tool = TOOL.get(land) owner = state.owners[tool] if tool is not None else -1 support = int(tool is not None and owner != leader) cost = max(0, BASE[land] - expert - support) return land, cost, expert, support, owner, tool def walk(state, branch=0): if state.ended or branch != 0 or state.segment >= 6: return state land, cost, expert, *detail = burden(state) leader = state.order[0] if state.hp[leader] == 0 or state.hp[leader] < cost: return state hp = list(state.hp) hp[leader] -= cost segment = state.segment + 1 ended = 'arrived' if segment == 6 else 'rescue' if not any(hp) else '' entry = (segment, land, leader, cost, expert, *detail, tuple(hp)) return replace(state, segment=segment, hp=tuple(hp), path=state.path + (land,), log=state.log + (entry,), ended=ended) def lead(state, person): if state.ended or person not in range(3) or state.hp[person] == 0: return state order = (person,) + tuple(p for p in state.order if p != person) return replace(state, order=order) def editable(state): return not state.ended and state.segment == 0 def give(state, tool, person): if not editable(state) or tool not in range(3) or person not in range(3): return state owners = list(state.owners) owners[tool] = person if owners.count(person) > 2: return state return replace(state, owners=tuple(owners)) def step(state, action): # Invalid UI/API actions are harmless; parse before indexing. if not isinstance(action, str): return state bits = action.split(':') try: if len(bits) == 2 and bits[0] == 'walk': return walk(state, int(bits[1])) if len(bits) == 2 and bits[0] == 'lead': return lead(state, int(bits[1])) if len(bits) == 3 and bits[0] == 'give': return give(state, int(bits[1]), int(bits[2])) except (ValueError, TypeError): pass return state STAGE = 3 KEYS = ('RETURN', 'R', '1', '2', '3', 'L', 'T', 'G') DEMO_KEYS = ('2', 'G', 'RETURN') class App: def __init__(self): self.reset() def reset(self, case=None, variant=None): self.state = new() self.person = 0 self.tool = 0 self.branch = 0 self.gated = False self.message = 'READY' def apply(self, action): old = self.state self.state = step(self.state, action) self.message = action if self.state != old else 'BLOCKED / NO CHANGE' if self.state.segment != old.segment: self.branch = 0 def control(self, pressed, held): pressed, held = set(pressed), set(held) if 'R' in pressed: self.reset() self.gated = True return if self.gated: if not pressed and not held: self.gated = False return if len(pressed | held) > 1: self.gated = True self.message = 'RELEASE ALL KEYS' return if len(pressed) != 1: return key = next(iter(pressed)) if key not in KEYS: return self.gated = True if self.state.ended: self.message = 'ENDED - R TO RESET' return if key in ('1', '2', '3'): self.person = int(key)-1 self.message = 'SELECTED '+NAMES[self.person] elif key == 'L': self.apply('lead:'+str(self.person)) elif key == 'T': self.tool = (self.tool+1)%3 self.message = 'SELECTED '+TOOLS[self.tool] elif key == 'G': self.apply('give:'+str(self.tool)+':'+str(self.person)) elif key == 'RETURN': self.apply('walk:0') def draw(self, p): state = self.state p.cls(1) p.text(12, 10, '090 NIGHT EXPEDITION / STAGE '+str(STAGE), 10) p.text(12, 25, 'SEGMENT '+str(state.segment)+'/6'+' '+(state.ended.upper() or 'WALKING'), 11) for i in range(6): p.rect(12+i*77, 43, 70, 21, 3 if i < state.segment else 2) p.text(18+i*77, 50, str(i+1)+':'+ '/'.join((ROUTE[i],)), 7) for person in range(3): x = 12+person*156 p.rectb(x, 78, 144, 106, 10 if person == self.person else 5) p.text(x+8, 88, str(person+1)+' '+NAMES[person], 7) p.text(x+8, 103, 'LEADER' if person == state.order[0] else 'REAR', 11) p.text(x+8, 118, 'HP '+str(state.hp[person])+'/5 SKILL '+SKILLS[person], 6) for n in range(5): p.rect(x+8+n*24, 133, 18, 9, 11 if n < state.hp[person] else 5) owned = ''.join('LRB'[j] for j in range(3) if state.owners[j] == person) p.text(x+8, 160, 'TOOLS '+(owned or '-'), 10) if not state.ended: land, cost, expert, *details = burden(state, self.branch) p.text(12, 199, 'NEXT '+LAND[land]+' COST '+str(cost)+' EXPERT -'+str(expert), 7) p.text(12, 213, 'SUPPORT -'+str(details[0])+' / ONLY THE LEADER PAYS', 6) p.text(12, 229, 'ENTER WALKS / 1 2 3 SELECT / L MAKE LEADER', 6) p.text(12, 247, 'TOOL '+TOOLS[self.tool]+' / '+('GIVE OPEN' if editable(state) else 'GIVE LOCKED'), 10) p.text(12, 263, 'T NEXT TOOL / G GIVE TO SELECTED / LIMIT 2', 6) p.text(12, 281, 'READING AND SELECTION DO NOT ADVANCE THE PATH.', 7) p.text(12, 301, 'R RESET / ESC QUIT', 6) p.text(12, 337, self.message.upper(), 11) def parse_args(argv=None): """すべてのCLI検証はPyxelをimportする前に終える。""" import argparse from pathlib import Path parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--demo-checkpoint', action='store_true') parser.add_argument('--frames', type=int) parser.add_argument('--screenshot') parser.add_argument('--state-output') args = parser.parse_args(argv) if not args.headless_smoke and (args.demo_checkpoint or args.frames is not None or args.screenshot or args.state_output): parser.error('QA options require --headless-smoke') minimum = 2 * len(DEMO_KEYS) + 4 if args.demo_checkpoint else 4 if args.frames is None: args.frames = minimum if not minimum <= args.frames <= 600: parser.error('--frames must be between '+str(minimum)+' and 600') for name, suffix in (('screenshot', '.png'), ('state_output', '.json')): value = getattr(args, name) if value is not None and (not value.strip() or Path(value).suffix.lower() != suffix): parser.error('--'+name.replace('_', '-')+' requires a '+suffix+' file path') return args def main(argv=None): args = parse_args(argv) from pathlib import Path import json import pyxel as p app = App() p.init(480, 352, title='090 Night Expedition', fps=30, display_scale=2, headless=args.headless_smoke) keys = {key: getattr(p, 'KEY_'+key) for key in KEYS} finished = False def update(): if args.headless_smoke: index = p.frame_count // 2 pressed = ({DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set()) app.control(pressed, pressed) else: app.control({k for k, v in keys.items() if p.btnp(v)}, {k for k, v in keys.items() if p.btn(v)}) def draw(): nonlocal finished app.draw(p) if args.headless_smoke and p.frame_count >= args.frames-1 and not finished: finished = True if args.screenshot: path = Path(args.screenshot).resolve() path.parent.mkdir(parents=True, exist_ok=True) p.screenshot(str(path), scale=2) if args.state_output: path = Path(args.state_output).resolve() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(asdict(app.state), ensure_ascii=False, indent=2)+'\n', encoding='utf-8') p.quit() p.run(update, draw) if __name__ == '__main__': main()