# 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. """地下商人の帰還・教材2/5。ゲーム内の金額は架空の規則です。""" from dataclasses import dataclass, replace KINDS = ('C', 'M', 'O') # 晶石、きのこ、鉄鉱 BASE = ((2, 2, 2), (2, 3, 3)) TOOLS = {'C': 'L', 'M': 'K', 'O': 'T'} # 灯、冷石、磁石 BONUS = ((4, 0, 0), (0, 2, 2)) START_CARGO = ('C1', 'L1', 'M1', 'K1') @dataclass(frozen=True) class State: cargo: tuple = START_CARGO coins: int = 0 sold: tuple = () def valid_slot(slot): return type(slot) is int and 0 <= slot < 4 def enhanced(cargo, slot): if not valid_slot(slot) or not cargo[slot] or cargo[slot][0] not in TOOLS: return False tool = TOOLS[cargo[slot][0]] # 端を越えない。anyなので左右に道具があっても一度だけ加算。 return any(0 <= other < 4 and cargo[other] and cargo[other][0] == tool for other in (slot-1, slot+1)) def price(cargo, slot, market=0): if not valid_slot(slot) or type(market) is not int or market not in (0, 1): return 0 item = cargo[slot] if not item or item[0] not in KINDS: return 0 kind = KINDS.index(item[0]) return BASE[market][kind] + (BONUS[market][kind] if enhanced(cargo, slot) else 0) def step(state, action): shapes = {'sell': 2} if not isinstance(action, (tuple, list)) or not action: return state, "INVALID ACTION" op = action[0] if not isinstance(op, str) or op not in shapes or len(action) != shapes[op]: return state, "INVALID ACTION" cargo = list(state.cargo) market = 0 slot = action[1] if not valid_slot(slot): return state, "INVALID SLOT" if op == "sell": if not cargo[slot]: return state, "EMPTY SLOT" item = cargo[slot] # 売る直前の配置で値を決め、その枠だけ空にする。 value = price(state.cargo, slot, market) cargo[slot] = '' return replace(state, cargo=tuple(cargo), coins=state.coins+value, sold=state.sold+((item, market, value),)), "SOLD FOR "+str(value) return state, "INVALID ACTION" class App: def __init__(self): self.reset() def reset(self): self.state = State() self.selected = 0 self.locked = False self.message = "CHOOSE A SLOT" def act(self, action): self.state, self.message = step(self.state, action) def control(self, pressed, held): # 確定後も複数キー拒否後も全解放を待つ。Rもゲート中は待機。 if self.locked: if not held: self.locked = False return if 'R' in pressed: self.reset() self.locked = True return if len(held) > 1 or len(pressed) > 1: self.locked = bool(held) return if len(pressed) != 1 or pressed != held: return key = next(iter(pressed)) if key in ('LEFT', 'RIGHT'): self.selected = (self.selected+(1 if key == 'RIGHT' else -1)) % 4 elif key == 'Z': self.act(('sell', self.selected)) else: return self.locked = True def draw(self, p): state = self.state p.cls(1) p.text(8, 8, "CARGO MERCHANT / STAGE 2", 10) p.text(8, 22, "MARKET 1 COINS "+str(state.coins), 7) p.text(8, 34, "PRACTICE PRESET / R RESTARTS", 6) for slot, item in enumerate(state.cargo): x = 8+slot*90 p.rect(x, 52, 84, 94, 2) p.rectb(x, 52, 84, 94, 10 if slot == self.selected else 5) p.text(x+5, 59, str(slot+1)+": "+(item or 'EMPTY'), 7) if item: color = {'C': 12, 'M': 8, 'O': 13, 'L': 10, 'K': 6, 'T': 9}[item[0]] p.circ(x+42, 87, 12, color) p.text(x+40, 85, item[0], 1) if enhanced(state.cargo, slot): p.text(x+5, 103, "ENHANCED", 11) for market in range(1): quote = str(price(state.cargo, slot, market)) p.text(x+5, 117+12*market, "M"+str(market+1)+" $"+quote, 7) p.text(8, 156, "L -> C K -> M T -> O / ADJACENT ONLY", 6) p.text(8, 168, "MARKET 1: "+" ".join(k+" "+str(BASE[0][i])+"+"+str(BONUS[0][i]) for i, k in enumerate(KINDS)), 7) p.text(8, 180, "SELLING A TOOL REMOVES ITS BONUS NOW", 8) p.text(8, 198, "LEFT/RIGHT SELECT / Z SELL", 10) p.text(8, 242, self.message, 11) p.text(8, 276, "LEFT/RIGHT SELECT Z CONFIRM R RESET", 7) p.text(8, 290, "ESC QUIT / FICTIONAL GAME PRICES", 6) DEMO_KEYS = ('RIGHT', 'Z') def main(headless=False, screenshot=None, demo=False, preview=False): from pathlib import Path from dataclasses import asdict import json import pyxel as p target = Path(screenshot).resolve() if screenshot else None if target: target.parent.mkdir(parents=True, exist_ok=True) app = App() p.init(368, 304, title='Cargo Merchant', fps=30, display_scale=2, headless=headless) keys = {key: getattr(p, 'KEY_'+key) for key in ('LEFT', 'RIGHT', 'UP', 'DOWN', 'Z', 'SPACE', 'X', 'R')} demo_keys = DEMO_KEYS limit = max(12, 2*len(demo_keys)+4) if demo or preview else 12 def update(): if headless and p.frame_count >= limit: if target: p.screenshot(str(target), scale=2) record = {'stage': 2, 'mode': 'preview' if preview else 'checkpoint' if demo else 'initial', 'input_kind': 'synthetic controller keys; not OS/human input', 'frames': p.frame_count, 'state': asdict(app.state), 'selected': app.selected, 'message': app.message} target.with_suffix('.json').write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8') p.quit() return if headless and (demo or preview): index = p.frame_count//2 pressed = {demo_keys[index]} if p.frame_count%2 == 0 and index < len(demo_keys) else set() app.control(pressed, pressed) else: app.control({key for key, code in keys.items() if p.btnp(code)}, {key for key, code in keys.items() if p.btn(code)}) p.run(update, lambda: app.draw(p)) return app if __name__ == '__main__': import argparse parser = argparse.ArgumentParser() parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--demo-checkpoint', action='store_true') parser.add_argument('--demo-preview', action='store_true') parser.add_argument('--screenshot') args = parser.parse_args() main(args.headless_smoke, args.screenshot, args.demo_checkpoint, args.demo_preview)