# 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段階。Python 3.11+ / Pyxel 2.9.9。""" from dataclasses import dataclass, replace ITEMS = ("MUG", "STAR") NAMES = {"ハナ": "HANA", "レン": "REN", "ミナ": "MINA"} SELL_PRICE = 2 def label(name): if name.endswith("の友人"): return NAMES.get(name[:-3], "GUEST") + " FRIEND" return NAMES.get(name, "GUEST") QUEUE = (("ハナ", 0, "buy", ""), ("レン", 1, "reserve", "")) @dataclass(frozen=True) class State: phase: str = "customer" cash: int = 6 free: tuple = (1, 1) sold: int = 0 index: int = 0 reservations: tuple = () def initial(): return State() def shelf(s): return sum(s.free) + len(s.reservations) def customer(s): return QUEUE[s.index] if s.phase == "customer" else None def actions(s): if s.phase == "customer": yield ("skip",) if s.free[customer(s)[1]] > 0: yield ("serve",) def step(s, action): if type(action) not in (tuple, list) or not action or type(action[0]) is not str: raise ValueError("INVALID ACTION") if any(type(n) is not int for n in action[1:]): raise ValueError("INVALID ACTION COUNTS") action = tuple(action) if action not in actions(s): raise ValueError("ACTION NOT AVAILABLE") name, item, kind, origin = customer(s) free = list(s.free) cash, sold = s.cash, s.sold reservations = list(s.reservations) if action[0] == "skip": pass elif kind == "reserve": free[item] -= 1 reservations.append((name, item, 1 + 1)) else: free[item] -= 1 cash += SELL_PRICE sold += 1 index = s.index + 1 return replace(s, free=tuple(free), cash=cash, sold=sold, index=index, phase="end" if index == len(QUEUE) else "customer", reservations=tuple(reservations)) class App: """The pure model is also usable without importing Pyxel.""" def __init__(self): self.reset() def reset(self): self.state = initial() self.stock = [0, 0] self.item = 0 self.previous = set() self.gated = False self.message = "READY" self.history = [] def apply(self, action): try: after = step(self.state, action) except ValueError as error: self.message = str(error) return False # Undo is introduced in stage 5. self.state = after self.stock = [0, 0] self.message = action[0].upper() return True def control(self, down): down = set(down) & set(KEYS) fresh = down - self.previous self.previous = down if self.gated: if not down: self.gated = False return if not fresh: return self.gated = True if len(down) != 1: self.message = "RELEASE ALL KEYS" return key = next(iter(fresh)) if key == "R": self.reset() self.gated = True self.previous = down return s = self.state if s.phase == "end": self.message = "ENDED / R TO RESET" return if s.phase == "customer": if key == "Z": self.apply(("collect",) if customer(s)[2] == "pickup" else ("serve",)) elif key == "X": self.apply(("skip",)) def draw(self, p): s = self.state p.cls(1) p.text(10, 10, "TOMORROW'S SHOP / STAGE 2", 10) day = getattr(s, "day", 1) p.text(10, 26, "DAY %d %s" % (day, s.phase.upper()), 7) p.text(10, 40, "COINS %d SOLD %d OWNED %d" % (s.cash, s.sold, shelf(s)), 11) p.text(10, 55, "FICTIONAL COINS / NO REAL PURCHASES", 5) p.rectb(8, 70, 210, 105, 5) p.rectb(228, 70, 212, 105, 5) p.text(16, 78, "FREE STOCK", 6) for item in (0, 1): y = 98 + item * 28 p.text(16, y, ITEMS[item], 7) for n in range(s.free[item]): x = 60 + n * 20 if item == 0: p.rect(x, y - 3, 12, 12, 12) p.rectb(x + 10, y, 5, 6, 12) else: p.rect(x, y - 3, 10, 15, 10) p.text(x + 3, y + 1, "*", 1) p.text(174, y, "x%d" % s.free[item], 7) p.text(16, 156, "OWNED = FREE + RESERVED", 6) p.text(236, 78, "NAMED RESERVATIONS", 6) reservations = getattr(s, "reservations", ()) for row, (name, item, due) in enumerate(reservations): p.text(236, 95 + row * 13, "%s %s D%d" % (label(name), ITEMS[item], due), 10) if not reservations: p.text(236, 98, "NONE", 5) p.text(10, 187, "CUSTOMER QUEUE / ARROW = CURRENT", 7) queue = s.queue if hasattr(s, "queue") else QUEUE for row, (name, item, kind, origin) in enumerate(queue): prefix = "> " if row == s.index and s.phase == "customer" else " " line = prefix + label(name) + " / " + ITEMS[item] + " / " + kind.upper() p.text(10, 202 + row * 13, line, 10 if prefix == "> " else 6) if s.phase == "morning": p.text(10, 202, "BUY MUG %d STAR %d / SELECT %s" % (*self.stock, ITEMS[self.item]), 10) p.text(10, 219, "RESERVATIONS STILL OCCUPY SHELF SPACE", 6) p.text(10, 235, "LEFT/RIGHT ITEM UP/DOWN COUNT Z OPEN", 7) if s.phase == "close": p.text(10, 260, "Z CLOSE / PAY 2 COINS", 10) elif s.phase == "end": end_text = "RESERVED FOR DAY 2 / NEXT: STAGE 3" p.text(10, 260, end_text, 10) p.text(10, 287, self.message[:70], 11) p.text(10, 304, "Z SELL/RESERVE X SKIP R RESET ESC QUIT", 6) KEYS = ("LEFT", "RIGHT", "UP", "DOWN", "Z", "X", "U", "R") DEMO_KEYS = ('Z', 'Z') def parse_args(argv=None): """Validate all bounded QA flags before any Pyxel import or initialization.""" 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") args = parser.parse_args(argv) if (args.demo_checkpoint or args.screenshot or args.frames is not None) and not args.headless_smoke: parser.error("QA options require --headless-smoke") minimum = max(8, 2 * len(DEMO_KEYS) + 4) if args.demo_checkpoint else 8 if args.frames is None: args.frames = minimum if not minimum <= args.frames <= 600: parser.error("--frames must be between %d and 600" % minimum) if args.screenshot: path = Path(args.screenshot).expanduser().resolve() if path.suffix.lower() != ".png" or path.exists() or not path.parent.is_dir(): parser.error("screenshot must be a NEW .png file in an existing directory") args.screenshot = str(path) return args def main(argv=None): args = parse_args(argv) import json from dataclasses import asdict import pyxel as p app = App() p.init(448, 320, title="Tomorrow's Shop / lesson 2", fps=30, display_scale=2, headless=args.headless_smoke) keys = {key: getattr(p, "KEY_" + key) for key in KEYS} def update(): if args.headless_smoke and p.frame_count >= args.frames: if args.screenshot: p.screenshot(args.screenshot, scale=2) print(json.dumps({"state": asdict(app.state), "history_length": len(app.history)}, ensure_ascii=True), flush=True) p.quit() return if args.headless_smoke: index = p.frame_count // 2 down = {DEMO_KEYS[index]} if args.demo_checkpoint and p.frame_count % 2 == 0 and index < len(DEMO_KEYS) else set() else: down = {key for key, value in keys.items() if p.btn(value)} app.control(down) p.run(update, lambda: app.draw(p)) if __name__ == "__main__": main()