# 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. """ひと部屋の災害会議・教材5/5。架空の人物の有限模型。現実の防災助言ではありません。""" from pathlib import Path from dataclasses import dataclass, asdict, replace import json, hashlib VERSION = '086.1.0' ITEMS = ('灯り', '台車', '戸締り') COSTS = (1, 2, 1) @dataclass(frozen=True) class Person: name: str intro: str wish: tuple accept: tuple story: str @dataclass(frozen=True) class Board: id: str title: str budget: int people: tuple def tree(v): return tuple((tree(x) for x in v)) if isinstance(v, list) else v DATA = [{'id': 'A', 'title': '暗くなる前', 'budget': 4, 'people': [{'name': 'ミナ', 'intro': '早めに船へ乗りたい世帯', 'wish': ['first', 0], 'accept': ['or', ['first', 0], ['item', 0]], 'story': '暗い待合が心配。灯りがあれば後便でも大丈夫。'}, {'name': 'コウ', 'intro': '川沿いで店を営む世帯', 'wish': ['item', 2], 'accept': ['and', ['item', 2], ['item', 1]], 'story': '戸締りだけでなく、台車で店の品も運びたい。'}, {'name': 'ハル', 'intro': '知り合いの隣が安心な世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 0], ['late', 2]]], 'story': 'ミナが初便なら、自分は後便で見送りを引き受ける。'}]}, {'id': 'B', 'title': '荷物の多い朝', 'budget': 4, 'people': [{'name': 'ソラ', 'intro': '荷物をゆっくり運びたい世帯', 'wish': ['item', 1], 'accept': ['or', ['item', 1], ['and', ['late', 0], ['item', 0]]], 'story': '台車がなくても、灯りの下で後便まで準備できる。'}, {'name': 'ナギ', 'intro': '先発で店を離れる世帯', 'wish': ['first', 1], 'accept': ['and', ['first', 1], ['item', 2]], 'story': '初便がよい。店の戸締りも済ませてから出たい。'}, {'name': 'トワ', 'intro': 'ソラの荷物を気にかける世帯', 'wish': ['same', 0, 2], 'accept': ['or', ['same', 0, 2], ['item', 1]], 'story': 'ソラと別の便でも、台車があるなら安心できる。'}]}, {'id': 'C', 'title': '最後の見送り', 'budget': 4, 'people': [{'name': 'ユズ', 'intro': '最後を見送ってから行く世帯', 'wish': ['late', 0], 'accept': ['and', ['late', 0], ['item', 0]], 'story': '後便で見送りたい。待合の灯りも必要。'}, {'name': 'レン', 'intro': '店の戸を気にする世帯', 'wish': ['item', 2], 'accept': ['or', ['item', 2], ['and', ['first', 1], ['item', 1]]], 'story': '戸締りか、初便で台車と品を運ぶ方法ならよい。'}, {'name': 'アオ', 'intro': 'レンとの同行を考える世帯', 'wish': ['same', 1, 2], 'accept': ['or', ['same', 1, 2], ['and', ['first', 2], ['late', 0]]], 'story': '別便でも、自分が初便でユズが見送るならよい。'}]}] BOARDS = tuple((Board(d['id'], d['title'], d['budget'], tuple((Person(p['name'], p['intro'], tree(p['wish']), tree(p['accept']), p['story']) for p in d['people']))) for d in DATA)) @dataclass(frozen=True) class Outcome: late: int equipment: int wished: tuple satisfied: tuple ready: tuple first_depart: int first_arrival: int returned: int second_depart: int final_arrival: int spare: int @dataclass(frozen=True) class State: asked: int = 0 heard: int = 0 questions: int = 3 late: int = 2 equipment: int = 0 submitted: object = None clock: int = 0 edits: int = 0 events: tuple = () @dataclass(frozen=True) class Action: kind: str value: int = 0 def initial(b): return State() def digest(s): return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, ensure_ascii=False).encode('utf-8')).hexdigest() def cost(mask): return sum((c for i, c in enumerate(COSTS) if mask >> i & 1)) def evaluate(expr, late, mask): op = expr[0] if op == 'first': return late != expr[1] if op == 'late': return late == expr[1] if op == 'same': return (late == expr[1]) == (late == expr[2]) if op == 'item': return bool(mask >> expr[1] & 1) if op == 'and': return evaluate(expr[1], late, mask) and evaluate(expr[2], late, mask) if op == 'or': return evaluate(expr[1], late, mask) or evaluate(expr[2], late, mask) raise ValueError('Unknown expression') def expression(b, expr): op = expr[0] if op == 'first': return b.people[expr[1]].name + '初便' if op == 'late': return b.people[expr[1]].name + '後便' if op == 'same': return b.people[expr[1]].name + 'と' + b.people[expr[2]].name + '同便' if op == 'item': return ITEMS[expr[1]] return '(' + expression(b, expr[1]) + (' かつ ' if op == 'and' else ' または ') + expression(b, expr[2]) + ')' def outcome(b, late, mask): if late not in range(3) or mask not in range(8) or cost(mask) > b.budget: return None wished = tuple((evaluate(p.wish, late, mask) for p in b.people)) satisfied = tuple((evaluate(p.accept, late, mask) for p in b.people)) ready = tuple((1 if ok else 2 if w else 3 for ok, w in zip(satisfied, wished))) first = max((ready[i] for i in range(3) if i != late)) returned = first + 2 second = max(returned, ready[late]) return Outcome(late, mask, wished, satisfied, ready, first, first + 1, returned, second, second + 1, b.budget - cost(mask)) def public(b, s, compare=False): known = s.heard | (7 if compare or s.submitted else 0) people = [] o = outcome(b, s.late, s.equipment) for i, p in enumerate(b.people): seen = bool(known >> i & 1) people.append({'name': p.name, 'intro': p.intro, 'wish': expression(b, p.wish), 'wished': evaluate(p.wish, s.late, s.equipment), 'known': seen, 'story': p.story if seen else 'まだ事情を聞いていない', 'accept': expression(b, p.accept) if seen else '事情の受入式は未確認', 'ready': o.ready[i] if seen and o else None, 'satisfied': o.satisfied[i] if seen and o else None}) return {'people': people, 'spare': b.budget - cost(s.equipment), 'known': known, 'all_known': known == 7, 'outcome': asdict(o) if known == 7 and o else None, 'arrival_range': (4, 6) if o else None} def step(b, s, a): if a.kind == 'reveal': return replace(s, heard=7) if a.kind == 'revise' and s.submitted: return replace(s, submitted=None, clock=0, heard=7, events=('計画を戻しました。見た事情と前の計画は残ります。',)) if a.kind == 'tick' and s.submitted: o = s.submitted if s.clock >= o.final_arrival: return s t = s.clock + 1 notes = [] for i, ready in enumerate(o.ready): if t == ready: notes.append(b.people[i].name + 'の準備完了') if t == o.first_depart: notes.append('初便の二世帯が出発') if t == o.first_arrival: notes.append('初便の二世帯が高台へ到着') if t == o.returned: notes.append('船が町へ戻った') if t == o.second_depart: notes.append('後便の' + b.people[o.late].name + 'が出発') if t == o.final_arrival: notes.append('全世帯が高台へ到着') return replace(s, clock=t, events=tuple(notes) or ('準備と渡航が進む',)) if s.submitted: return s if a.kind == 'ask' and a.value in range(3): bit = 1 << a.value if s.heard & bit: return s if s.questions <= 0: return s return replace(s, asked=s.asked | bit, heard=s.heard | bit, questions=s.questions - 1, edits=s.edits + 1, events=(b.people[a.value].story,)) if a.kind == 'late' and a.value in range(3): return replace(s, late=a.value, edits=s.edits + (s.late != a.value), events=('後便は' + b.people[a.value].name + '。他の二世帯は初便。',)) if a.kind == 'item' and a.value in range(3): return replace(s, equipment=s.equipment ^ 1 << a.value, edits=s.edits + 1, events=(ITEMS[a.value] + 'を切り替えた',)) if a.kind == 'submit': o = outcome(b, s.late, s.equipment) if o is None: return s return replace(s, submitted=o, heard=7, clock=0, edits=s.edits + 1, events=('計画決定。全員の事情を開きます。',)) return s class Timeline: def __init__(self, b, heard=0): self.board = b self.state = replace(initial(b), heard=heard) self.history = [] self.inputs = [] self.archive = [] self.journal = [] self.initial_heard = heard def act(self, a): n = step(self.board, self.state, a) if n == self.state: return False self.history.append(self.state) self.inputs.append(a) self.journal.append(asdict(a)) self.state = n if a.kind == 'submit': self.archive.append(n.submitted) return True def undo(self): if not self.history: return False heard = self.state.heard old = self.history.pop() self.state = replace(old, heard=old.heard | heard) self.inputs.pop() self.journal.append({'kind': 'undo', 'value': 0}) return True STAGE = 5 BOARD = BOARDS[0] LABELS = ("MINA","KOU","HARU") ITEM_LABELS = ("LIGHT","CART","LOCK") def formula(expr): op=expr[0] if op=='first':return LABELS[expr[1]]+' FIRST' if op=='late':return LABELS[expr[1]]+' LATE' if op=='same':return LABELS[expr[1]]+' SAME '+LABELS[expr[2]] if op=='item':return ITEM_LABELS[expr[1]] return '('+formula(expr[1])+(' & ' if op=='and' else ' | ')+formula(expr[2])+')' class App: def __init__(self): self.reset() def reset(self): heard=self.state.heard if hasattr(self,"state") else 0 self.timeline=Timeline(BOARD,heard) self.person=0 self.field=0 self.gated=False self.message='READY' @property def state(self): return self.timeline.state def commit(self,kind,value=0): old=self.state if kind=='undo':self.timeline.undo() else:self.timeline.act(Action(kind,value)) self.message=kind.upper() if self.state!=old else 'NO CHANGE / REFUSED' def control(self,pressed,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));self.gated=True if key=='X':self.commit('undo');return if key=='V':self.commit('revise');return if key=='RETURN':self.commit('submit');return if key=='SPACE':self.commit('tick');return if self.state.submitted:return if key in ('LEFT','RIGHT'): self.person=(self.person+(-1 if key=='LEFT' else 1))%3 elif key in ('UP','DOWN'): self.field=(self.field+(-1 if key=='UP' else 1))%4 elif key=='Z': self.commit('late',self.person) if self.field==0 else self.commit('item',self.field-1) elif key=='H':self.commit('ask',self.person) def draw(self,p): s=self.state;p.cls(1) p.text(12,10,'ROOM COUNCIL / STAGE '+str(STAGE)+' / A',10) p.text(12,27,'FICTIONAL TOY / NOT REAL EMERGENCY PLANNING',6) p.text(12,44,'SPENT '+str(cost(s.equipment))+' / BUDGET '+str(BOARD.budget)+' / SPARE '+str(BOARD.budget-cost(s.equipment)),11 if cost(s.equipment)<=BOARD.budget else 8) for i,name in enumerate(LABELS): y=75+i*68 p.rectb(10,y-7,576,61,10 if i==self.person else 5) p.text(18,y,name+(' : LATE BOAT' if i==s.late else ' : FIRST BOAT'),7) p.text(18,y+15,'WISH '+formula(BOARD.people[i].wish)+' = '+str(evaluate(BOARD.people[i].wish,s.late,s.equipment)),6) known=bool(s.heard>>i&1) if known: ready=ready_for(BOARD,s,i) if STAGE==3 else outcome(BOARD,s.late,s.equipment).ready[i] if outcome(BOARD,s.late,s.equipment) else None p.text(18,y+30,'ACCEPT '+formula(BOARD.people[i].accept),14) p.text(470,y+45,'READY '+str(ready),11) else:p.text(18,y+30,'ACCEPT ? / READY 1..3 / PRESS H TO LISTEN',14) values=[LABELS[s.late]]+[('ON' if s.equipment>>i&1 else 'OFF') for i in range(3)] for i,name in enumerate(('LATE',)+ITEM_LABELS): x=12+i*145 p.rectb(x,285,138,27,10 if i==self.field else 5) p.text(x+5,295,name+' '+values[i],7) p.text(12,326,'QUESTIONS '+str(s.questions)+' / HEARD MASK '+str(s.heard),6) view=public(BOARD,s) o=s.submitted if o: p.text(12,344,'CLOCK '+str(s.clock)+' / ALL ARRIVE '+str(o.final_arrival)+' / READY '+str(o.ready),11) p.text(12,361,'FIRST '+str(o.first_depart)+' -> '+str(o.first_arrival)+' / RETURN '+str(o.returned)+' / SECOND '+str(o.second_depart)+' -> '+str(o.final_arrival),7) p.text(12,378,'ALL ARRIVED' if s.clock>=o.final_arrival else 'SPACE: ADVANCE ONE TICK',10) elif view['outcome']: p.text(12,344,'KNOWN PLAN / ALL ARRIVE AT '+str(view['outcome']['final_arrival']),11) elif view['arrival_range']: p.text(12,344,'ARRIVAL RANGE 4..6 / UNHEARD REASONS HIDDEN',14) else:p.text(12,344,'OVER BUDGET / SUBMISSION REFUSED',8) p.text(12,395,'HISTORY '+str(len(self.timeline.history))+' / SUBMITTED ARCHIVE '+str(len(self.timeline.archive)),6) p.text(12,416,self.message,10) p.text(12,438,'LEFT/RIGHT: PERSON / UP/DOWN: FIELD / Z: CHANGE / R: RESET',7) p.text(12,455,'H: LISTEN / ENTER: SUBMIT / SPACE: TICK / V: REVISE / ESC: QUIT',7) p.text(12,472,'X: ONE STATE UNDO; HEARD REASONS AND SUBMITTED ARCHIVE STAY',7) def make_demo_keys(): return ('H','X','DOWN','DOWN','Z','DOWN','Z','RETURN','SPACE','V') def main(headless=False,screenshot=None,demo=False): import pyxel as p p.init(600,500,title='Room council lesson '+str(STAGE),fps=30,display_scale=2,headless=headless) app=App();sequence=make_demo_keys() if demo else () keys={'LEFT':p.KEY_LEFT,'RIGHT':p.KEY_RIGHT,'UP':p.KEY_UP,'DOWN':p.KEY_DOWN,'RETURN':p.KEY_RETURN,'SPACE':p.KEY_SPACE,'Z':p.KEY_Z,'X':p.KEY_X,'R':p.KEY_R,'H':p.KEY_H,'V':p.KEY_V} limit=max(20,2*len(sequence)+8) def update(): if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit: if screenshot:app.draw(p);p.screen.save(screenshot,1) p.quit() if headless: if demo: index=p.frame_count//2 pressed={sequence[index]} if p.frame_count%2==0 and index