# -*- coding: utf-8 -*- # 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. """Standalone tutorial: Japanese instructions in LESSON.md; assets not needed.""" STAGE = 3 """Immutable, simultaneous, unhurried two-role kitchen. Runtime: stdlib only.""" from dataclasses import dataclass, replace, asdict import hashlib, json VERSION = '088-v1.0.1' DIR = ((0, -1), (1, 0), (0, 1), (-1, 0)) SHELVES = ((0, 0), (0, 2)) HEAT = ((2, 0), (2, 2)) @dataclass(frozen=True) class Kitchen: id: str title: str shared: tuple walls: tuple orders: tuple counter: tuple KITCHENS = (Kitchen('A', 'まんなかで会う', ((3, 0), (3, 2)), (), tuple('UBUBUB'), (6, 1)),) @dataclass(frozen=True) class Input: dirs: tuple = (-1, -1) ops: tuple = (False, False) give: tuple = (False, False) @dataclass(frozen=True) class Dish: kind: str cooked: bool = False @dataclass(frozen=True) class Pan: dish: Dish remain: int placed: int @dataclass(frozen=True) class State: t: int = 0 pos: tuple = ((1, 1), (5, 1)) face: tuple = (0, 1) hands: tuple = (None, None) heat: tuple = (None, None) shared: tuple = (None, None) order: int = 0 created: int = 0 returned: int = 0 passes: int = 0 conflicts: int = 0 events: tuple = () complete: bool = False def new(k): return State() def walk(k, p, d, role): if d not in range(4): return p q = (p[0] + DIR[d][0], p[1] + DIR[d][1]) lo, hi = (0, 3) if role == 0 else (3, 6) return q if lo <= q[0] <= hi and 0 <= q[1] < 3 and (q not in SHELVES + HEAT + (k.counter,) + k.walls) else p def target(k, p, face): if p in k.shared: return ('shared', k.shared.index(p)) q = (p[0] + DIR[face][0], p[1] + DIR[face][1]) if q in k.shared: return ('shared', k.shared.index(q)) if q in SHELVES: return ('shelf', SHELVES.index(q)) if q in HEAT: return ('heat', HEAT.index(q)) if q == k.counter: return ('counter', 0) return ('none', 0) def step(k, s, a, handoff=True): if s.complete: return s pos = list(s.pos) face = list(s.face) hands = list(s.hands) heat = list(s.heat) shared = list(s.shared) created = s.created returned = s.returned order = s.order passes = s.passes conflicts = s.conflicts events = [] tick = s.t + 1 for r in range(2): if not a.ops[r] and (not a.give[r]) and (a.dirs[r] in range(4)): face[r] = a.dirs[r] pos[r] = walk(k, s.pos[r], a.dirs[r], r) participants = set() targets = [target(k, pos[r], face[r]) if a.ops[r] and (not a.give[r]) and (r not in participants) else None for r in range(2)] collision = targets[0] is not None and targets[0] == targets[1] and (targets[0][0] == 'shared') if collision: conflicts += 1 events.append('同じ台、ひとりずつ') targets = [None, None] for r, tg in enumerate(targets): if tg is None: continue typ, i = tg hand = hands[r] if typ == 'shared': if hand is None and shared[i] is not None: hands[r] = shared[i] shared[i] = None events.append('共有台から受取り') elif hand is not None and shared[i] is None: shared[i] = hand hands[r] = None events.append('共有台へ置きました') else: events.append('空いた手と台で受け渡し') elif typ == 'shelf' and r == 0: kind = ('U', 'B')[i] if hand is None: hands[r] = Dish(kind) created += 1 events.append('スープの材料' if kind == 'U' else 'パンの材料') elif not hand.cooked and hand.kind == kind: hands[r] = None returned += 1 events.append('材料を棚へ戻しました') else: events.append('生の同じ材料だけ戻せます') elif typ == 'heat' and r == 0: if hand is not None and (not hand.cooked) and (heat[i] is None): heat[i] = Pan(hand, 3 if hand.kind == 'U' else 2, tick) hands[r] = None events.append('加熱開始。次の刻から進みます') elif hand is None and heat[i] is not None and (heat[i].remain == 0): hands[r] = heat[i].dish heat[i] = None events.append('出来上がりを受取り') else: events.append('空手で完成皿を。生皿は空いた台へ') elif typ == 'counter' and r == 1: if hand is not None and hand.cooked and (hand.kind == k.orders[order]): order += 1 hands[r] = None events.append('いただきます! ' + str(order) + '皿目') else: events.append('先頭の完成皿をどうぞ。皿は手元に戻ります') else: events.append('向きの先に自分の作業台はありません') for i, old in enumerate(s.heat): if old is not None and old.remain > 0 and (heat[i] == old): remaining = old.remain - 1 heat[i] = Pan(replace(old.dish, cooked=remaining == 0), remaining, old.placed) if remaining == 0: events.append('スープ完成!' if old.dish.kind == 'U' else 'パン完成!') complete = order == len(k.orders) if complete: events.append('ごちそうさま。二人も座ろう。') return State(tick, tuple(pos), tuple(face), tuple(hands), tuple(heat), tuple(shared), order, created, returned, passes, conflicts, tuple(events), complete) def conservation(s): return s.created - s.returned - s.order == sum((x is not None for x in s.hands + s.heat + s.shared)) def digest(s): return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode()).hexdigest() KEYS = {'W', 'D', 'S', 'A', 'E', 'Q', 'UP', 'RIGHT', 'DOWN', 'LEFT', 'M', 'N', 'P', 'H', 'X', 'R', 'Z', 'RETURN', 'SPACE', '1', '2', '3', 'B', 'F12', 'ESCAPE'} class Controller: def __init__(self): self.level = 0 self.variant = False self.title = True self.paused = False self.restart = False self.ready = set() self.previous = set() self.blocked = set() self.release_gate = False self.pending = set() self.clock = 0 self.history = [] self.actions = [] self.state = new(self.kitchen) @property def kitchen(self): return KITCHENS[self.level] def reset(self): self.state = new(self.kitchen) self.history = [] self.actions = [] self.clock = 0 self.pending.clear() self.restart = False self.paused = False self.ready = set() def barrier(self, down): self.blocked |= down self.release_gate = True self.pending.clear() self.clock = 0 def tick(self, a): old = self.state self.state = step(self.kitchen, old, a, not self.variant) if old != self.state: self.history.append(old) self.actions.append(asdict(a)) return 'tick' def feed(self, down): down = set(down) & KEYS self.blocked &= down fresh = down - self.previous - self.blocked self.previous = down active = down - self.blocked if self.release_gate: if not down: self.release_gate = False return None if 'F12' in fresh: return 'screenshot' if 'ESCAPE' in fresh: self.title = True self.barrier(down) return 'menu' if self.title: if 'RETURN' in fresh: self.title = False self.reset() self.barrier(down) return 'start' return None if 'R' in fresh: self.restart = not self.restart self.ready = set() self.barrier(down) return 'restart_request' if self.restart: if 'Z' in fresh: self.ready.add('C') if 'RETURN' in fresh: self.ready.add('S') if len(self.ready) == 2: self.reset() self.barrier(down) return 'restart_confirmed' return None if 'P' in fresh or 'H' in fresh: self.paused = not self.paused self.barrier(down) return 'pause' if self.paused: return None if self.state.complete: return None self.pending |= fresh & {'E', 'Q', 'M', 'N'} self.clock += 1 if self.clock < 15: return None self.clock -= 15 def direction(keys): a = [i for i, k in enumerate(keys) if k in active] return a[0] if len(a) == 1 else -1 a = Input((direction(('W', 'D', 'S', 'A')), direction(('UP', 'RIGHT', 'DOWN', 'LEFT'))), ('E' in self.pending, 'M' in self.pending), ('Q' in self.pending, 'N' in self.pending)) self.pending.clear() return self.tick(a) # Teaching ASCII renderer. Original input meanings are preserved. EVENT_LABELS = { '同じ台、ひとりずつ': 'SAME SHARED TABLE: BOTH OPERATIONS REJECTED', '共有台から受取り': 'PICKED UP FROM SHARED TABLE', '共有台へ置きました': 'PLACED ON SHARED TABLE', '空いた手と台で受け渡し': 'NEED ONE EMPTY HAND OR EMPTY TABLE', 'スープの材料': 'TOOK RAW SOUP U', 'パンの材料': 'TOOK RAW BREAD B', '材料を棚へ戻しました': 'RAW INGREDIENT RETURNED', '生の同じ材料だけ戻せます': 'RETURN ONLY MATCHING RAW INGREDIENT', '加熱開始。次の刻から進みます': 'HEATING STARTED; COUNTDOWN STARTS NEXT TICK', '出来上がりを受取り': 'PICKED UP COOKED DISH', '空手で完成皿を。生皿は空いた台へ': 'NEED EMPTY HAND + READY PAN, OR RAW DISH + EMPTY STOVE', '先頭の完成皿をどうぞ。皿は手元に戻ります': 'WRONG OR RAW ORDER: DISH RETAINED', '向きの先に自分の作業台はありません': 'NO USABLE STATION IN FRONT FOR THIS ROLE', 'スープ完成!': 'SOUP U COOKED', 'パン完成!': 'BREAD B COOKED', 'ごちそうさま。二人も座ろう。': 'ALL SIX ORDERS COMPLETE', '比較版: 共有台で受け渡します': 'DIRECT HANDOFF DISABLED; USE SHARED TABLE', '手渡しは隣のマスで': 'HANDOFF NEEDS ADJACENT CELLS', '片方が空手のときに手渡し': 'HANDOFF NEEDS EXACTLY ONE EMPTY HAND', 'はい、どうぞ!': 'DIRECT HANDOFF COMPLETE', } def event_label(event): if event.startswith('いただきます!'): return 'CORRECT COOKED ORDER SERVED' return EVENT_LABELS.get(event, 'EVENT: SEE JAPANESE STATE JSON') def dish_label(d): return '-' if d is None else d.kind + (' COOKED' if d.cooked else ' RAW') class App: def __init__(self): self.ui = Controller() self.message = 'ENTER START / RELEASE ALL AFTER TRANSITIONS' def control(self, down): event = self.ui.feed(down) if event: self.message = event.upper().replace('_', ' ') return event def draw(self, p): u, s, k = self.ui, self.ui.state, self.ui.kitchen p.cls(1) p.text(14, 12, 'LINKED KITCHEN / LESSON ' + str(STAGE) + ' / TEACHING UI', 10) p.text(14, 28, 'KITCHEN ' + k.id + ' TICK ' + str(s.t) + ' CLOCK ' + str(u.clock) + '/15 DIRECT HANDOFF ' + ('OFF' if u.variant or STAGE < 4 else 'ON'), 7) p.text(14, 48, 'ORDERS ' + ''.join(k.orders) + ' SERVED ' + str(s.order) + '/6 CREATED ' + str(s.created) + ' RETURNED ' + str(s.returned), 10) p.text(14, 62, 'U = SOUP / B = BREAD / TIME LIMIT: NONE / NO BURNING', 6) for y in range(3): for x in range(7): pos = (x, y); xx, yy = 14 + x * 70, 86 + y * 78 p.rect(xx, yy, 68, 76, 5 if x == 3 else 1 if x < 3 else 2) p.rectb(xx, yy, 68, 76, 0) if pos in k.walls: p.rect(xx + 3, yy + 3, 62, 70, 5);p.text(xx + 24, yy + 31, 'WALL', 7) elif pos in SHELVES: i = SHELVES.index(pos);p.text(xx + 5, yy + 8, 'SHELF ' + 'UB'[i], 10) p.text(xx + 5, yy + 60, 'E: RAW', 6) elif pos in HEAT: i=HEAT.index(pos);pan=s.heat[i];p.text(xx + 5, yy + 8, 'HEAT ' + str(i), 8) p.circb(xx + 34, yy + 38, 16, 8 if pan and pan.remain else 10) if pan: p.text(xx + 15, yy + 35, pan.dish.kind + ' ' + str(pan.remain), 10) p.text(xx + 5, yy + 60, 'READY' if pan.remain == 0 else 'HEATING', 7) elif pos in k.shared: i=k.shared.index(pos);p.rectb(xx + 3, yy + 16, 61, 47, 15) p.text(xx + 5, yy + 5, 'SHARED ' + str(i), 7) p.text(xx + 6, yy + 27, dish_label(s.shared[i]), 10) p.text(xx + 5, yy + 65, 'FOOT/FRONT', 6) elif pos == k.counter: p.text(xx + 6, yy + 9, 'COUNTER', 10) p.rect(xx + 6, yy + 29, 55, 19, 9) p.text(xx + 6, yy + 60, 'NEXT ' + (k.orders[s.order] if not s.complete else '-'), 7) for r in range(2): typ,i=target(k,s.pos[r],s.face[r]);dest=k.shared[i] if typ=='shared' else SHELVES[i] if typ=='shelf' else HEAT[i] if typ=='heat' else k.counter if typ=='counter' else None if dest: xx,yy=14+dest[0]*70,86+dest[1]*78;p.rectb(xx+1+r*2,yy+1+r*2,66-r*4,74-r*4,(12,14)[r]) for r in range(2): pos=s.pos[r];xx,yy=48+pos[0]*70,127+pos[1]*78 if s.pos[0]==s.pos[1]:xx+=(-9,9)[r] p.circ(xx,yy,10,(12,14)[r]);p.text(xx-2,yy-2,'CS'[r],0) dx,dy=DIR[s.face[r]];p.line(xx,yy,xx+dx*19,yy+dy*19,(12,14)[r]) p.text(521, 90, 'C COOK / BLUE', 12) p.text(521, 106, 'WASD MOVE', 7);p.text(521, 120, 'E OPERATE', 7);p.text(521, 134, 'Q GIVE', 7) p.text(521, 160, 'S SERVER / PINK', 14) p.text(521, 176, 'ARROWS MOVE', 7);p.text(521, 190, 'M OPERATE', 7);p.text(521, 204, 'N GIVE', 7) p.text(521, 229, 'PASSES ' + str(s.passes), 10);p.text(521, 245, 'CONFLICTS ' + str(s.conflicts), 10) p.text(521, 269, '2 TICKS / SECOND', 6) p.text(521, 287, 'C/S MAY OVERLAP', 6) p.text(14, 329, 'C HAND: ' + dish_label(s.hands[0]) + ' S HAND: ' + dish_label(s.hands[1]) + ' CONSERVED: ' + str(conservation(s)), 7) for i,event in enumerate(s.events[-2:]):p.text(14,347+i*15,event_label(event),10) p.text(14, 381, self.message, 11) p.text(14, 398, 'P/H PAUSE / R RESTART REQUEST: Z + ENTER AGREE, R CANCEL', 7) p.text(14, 413, 'ESC MENU / CLOSE WINDOW TO QUIT / F12 EVENT ONLY: SEE CLI', 6) p.text(14, 428, ('01: MOVEMENT ONLY' if STAGE == 1 else '02: RAW SHELVES + SHARED ONLY' if STAGE == 2 else '03: COOK + SERVE VIA TABLES' if STAGE == 3 else '1-3 KITCHEN + B COMPARE AT TITLE') + (' / PAUSED X UNDO 5' if STAGE == 5 else ''), 6) if s.complete: p.rect(122, 156, 428, 95, 1);p.rectb(122,156,428,95,11) p.text(144, 177, 'ALL SIX DISHES SERVED / BOTH ROLES COMPLETE',11) p.text(144, 200, 'SAME GOAL WITH SHARED TABLES',7) p.text(144, 223, 'NO TIME LIMIT / R REQUESTS A FRESH KITCHEN',6) if u.title or u.paused or u.restart: p.rect(91,102,490,208,1);p.rectb(91,102,490,208,10) if u.title: lines=['TWO ROLES, ONE KITCHEN', 'C: WASD / E OPERATE / Q GIVE', 'S: ARROWS / M OPERATE / N GIVE', 'OPERATE BEATS MOVE; GIVE BEATS OPERATE', 'ENTER START, THEN RELEASE ALL KEYS', '1-3 KITCHEN / B DIRECT HANDOFF COMPARISON' if STAGE>=4 else 'KITCHEN A ONLY / MORE RULES IN LATER STAGES', 'SYNTHETIC QA IS NOT A TWO-HUMAN PLAYTEST'] elif u.restart: lines=['RESTART REQUEST / WORLD STOPPED', 'C: Z AGREES S: ENTER AGREES', 'AGREED: ' + ','.join(sorted(u.ready)), 'R CANCELS / STATE KEPT UNTIL BOTH AGREE', 'R DOES NOT RELOAD PYTHON SOURCE'] else: lines=['PAUSED / ALL DISHES AND BOTH ROLES STOP', 'P/H RESUME, THEN RELEASE ALL KEYS', 'X RESTORES THE WHOLE KITCHEN UP TO 5 TICKS' if STAGE==5 else 'UNDO IS ADDED IN STAGE 05', 'R REQUESTS RESTART / ESC MENU', 'CLOCK AND PENDING OPERATIONS CLEARED ON TRANSITION'] for i,line in enumerate(lines):p.text(108,122+i*24,line,10 if i==0 else 7) def tap(app, keys): app.control(set()) return app.control(set(keys)) def start(app): tap(app, {'RETURN'}) app.control(set()) def logical_tick(app, keys=()): # For deterministic exercises: one controller window of 15 frames. for _ in range(15):app.control(set(keys)) def checkpoint(app): start(app) logical_tick(app, {'W'}) logical_tick(app, {'A'}) logical_tick(app, {'E'}) def main(headless=False, screenshot=None, state_json=None, demo=False): import pyxel as p from pathlib import Path p.init(680,450,title='088 lesson '+str(STAGE),fps=30,display_scale=1,quit_key=p.KEY_NONE,headless=headless) app=App() if demo:checkpoint(app) keys={k:getattr(p,'KEY_'+k) for k in KEYS} def update(): if headless: if p.frame_count>=3: app.draw(p) if screenshot:p.screen.save(screenshot,1) if state_json:Path(state_json).write_text(json.dumps({'stage':STAGE,'state':asdict(app.ui.state),'clock':app.ui.clock,'title':app.ui.title,'scope':'Actual headless Pyxel; synthetic controls; dummy audio; no physical keys or two-human play'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8') p.quit() return app.control({k for k,v in keys.items() if p.btn(v)}) p.run(update,lambda:app.draw(p)) if __name__=='__main__': import argparse from pathlib import Path parser=argparse.ArgumentParser(description='Standalone linked kitchen tutorial') parser.add_argument('--headless-smoke',action='store_true') parser.add_argument('--demo-checkpoint',action='store_true') parser.add_argument('--screenshot') parser.add_argument('--state-json') args=parser.parse_args() if (args.demo_checkpoint or args.screenshot or args.state_json) and not args.headless_smoke: parser.error('demo and outputs require --headless-smoke') for field,suffix in (('screenshot','.png'),('state_json','.json')): value=getattr(args,field) if value: output_path=Path(value).resolve() if output_path.suffix.lower()!=suffix or not output_path.parent.is_dir() or output_path.exists(): parser.error('output must be a new '+suffix+' in an existing directory') setattr(args,field,str(output_path)) main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)