# 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. """097 鏡の国の小競り合い / 教材 5。""" STAGE = 5 """097: immutable deterministic tactics; UI/rendering never alter rule time.""" from dataclasses import dataclass,asdict,replace from collections import deque from pathlib import Path import json,hashlib,os VERSION=1 TITLES={'A':'伸びる槍','B':'近すぎる重槌','C':'角の向こう'} EQUIPMENT={'A':'長槍','B':'重槌','C':'斜め刃'} LAYOUT={'A':(((0,1),(0,3),(5,1),(5,3)),(2,2),(1,2),((3,1),(3,3))), 'B':(((1,1),(1,3),(4,1),(4,3)),(2,2),(2,1),((3,2),)), 'C':(((0,2),(1,4),(5,0),(5,3)),(3,2),(2,3),((2,1),(3,1),(4,3)))} DIR=((0,-1),(-1,0),(0,1),(1,0)) @dataclass(frozen=True) class State: case:str='A';variant:str='both';pos:tuple=LAYOUT['A'][0];hp:tuple=(3,3,3,3);equipped:bool=False;left:int=2;round:int=0;flag:int=0;ended:str='';events:tuple=() def new(case='A',variant='both'):return State(case,variant,LAYOUT[case][0]) def inside(p):return 0<=p[0]<6 and 0<=p[1]<5 def md(a,b):return abs(a[0]-b[0])+abs(a[1]-b[1]) def living(s,p):return [i for i in range(4) if s.hp[i] and s.pos[i]==p] def weapon(s,i):return EQUIPMENT[s.case] if s.equipped and (i<2 or s.variant=='both') else '素手' def reach(s,i,p): if not s.hp[i] or s.pos[i]==p or not inside(p):return False a=s.pos[i];d=md(a,p);w=weapon(s,i);walls=LAYOUT[s.case][3] if p in walls:return False if d==1:return True if w=='長槍' and d==2 and (a[0]==p[0] or a[1]==p[1]): mid=((a[0]+p[0])//2,(a[1]+p[1])//2);return mid not in walls and not living(s,mid) if w=='斜め刃' and abs(a[0]-p[0])==abs(a[1]-p[1])==1:return not ((a[0],p[1]) in walls and (p[0],a[1]) in walls) return False def zone(s,i):return tuple((x,y) for y in range(5) for x in range(6) if reach(s,i,(x,y))) def damage(s,i):return 2 if weapon(s,i)=='重槌' else 1 def victory(s): if not any(s.hp[:2]):return replace(s,ended='lost') if not any(s.hp[2:]):return replace(s,ended='defeated') return s def attack(s,i,j): hp=list(s.hp);hp[j]=max(0,hp[j]-damage(s,i));return victory(replace(s,hp=tuple(hp))) def enemy_choice(s,i): targets=[j for j in range(2) if s.hp[j]] hit=[j for j in targets if reach(s,i,s.pos[j])] if hit:return ('attack',min(hit,key=lambda j:(s.hp[j],j))) # BFS stops next to targets. All living pieces and walls obstruct routes. blocked=set(LAYOUT[s.case][3])|{s.pos[j] for j in range(4) if s.hp[j] and j!=i};q=deque([(s.pos[i],None,0)]);seen={s.pos[i]};candidates=[] while q: p,first,dist=q.popleft() for j in targets: if md(p,s.pos[j])==1:candidates.append((dist+1,s.hp[j],j,first)) for dx,dy in DIR: n=(p[0]+dx,p[1]+dy) if inside(n) and n not in blocked and n not in seen:seen.add(n);q.append((n,first or n,dist+1)) if not candidates:return ('wait',None) best=min(candidates,key=lambda t:t[:3]);return ('move',best[3]) if best[3] is not None else ('wait',None) def enemy_phase(s): log=[] for i in (2,3): if s.ended:break if not s.hp[i]:continue kind,v=enemy_choice(s,i) if kind=='attack':before=s.hp[v];s=attack(s,i,v);log.append(f'敵{i-1}→味方{v+1} {before-s.hp[v]}ダメージ') elif kind=='move':p=list(s.pos);p[i]=v;s=replace(s,pos=tuple(p));log.append(f'敵{i-1}が{v[0]+1},{v[1]+1}へ移動') else:log.append(f'敵{i-1}は待つ') n=s.round+1;count=s.flag+1 if any(s.hp[i] and s.pos[i]==LAYOUT[s.case][1] for i in range(2)) else 0 end=s.ended or ('flag' if count>=2 else ('draw' if n>=10 else '')) return replace(s,left=2,round=n,flag=count,ended=end,events=tuple(log)) def legal(s): if s.ended:return () out=[] for i in range(2): if not s.hp[i]:continue for dx,dy in DIR: p=(s.pos[i][0]+dx,s.pos[i][1]+dy) if inside(p) and p not in LAYOUT[s.case][3] and not living(s,p):out.append(f'move:{i}:{p[0]}:{p[1]}') for j in (2,3): if s.hp[j] and reach(s,i,s.pos[j]):out.append(f'attack:{i}:{j}') if not s.equipped and md(s.pos[i],LAYOUT[s.case][2])==1:out.append(f'pick:{i}') out.append(f'wait:{i}') return tuple(out) def step(s,a): if a not in legal(s):return s bits=a.split(':');i=int(bits[1]);log='' if bits[0]=='move':p=list(s.pos);p[i]=(int(bits[2]),int(bits[3]));s=replace(s,pos=tuple(p));log=f'味方{i+1}が移動' elif bits[0]=='attack':s=attack(s,i,int(bits[2]));log=f'味方{i+1}が攻撃' elif bits[0]=='pick':s=replace(s,equipped=True);log=EQUIPMENT[s.case]+('を両軍へ' if s.variant=='both' else 'を味方だけへ') else:log=f'味方{i+1}は待つ' # Occupation continuity is broken as soon as the flag becomes empty. if not any(s.hp[j] and s.pos[j]==LAYOUT[s.case][1] for j in (0,1)):s=replace(s,flag=0) s=replace(s,left=s.left-1,events=(log,)) return enemy_phase(s) if not s.ended and s.left==0 else s def forecast(s,a):return step(s,a) def digest(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True).encode()).hexdigest() def unpack(d): if not isinstance(d,dict) or set(d)!=set(asdict(new())):raise ValueError('状態の形式が違います') if d['case'] not in TITLES or d['variant'] not in ('both','ally'):raise ValueError('未知の状況') if type(d['equipped']) is not bool or type(d['left']) is not int or d['left'] not in (0,1,2):raise ValueError('行動数') if type(d['round']) is not int or not 0<=d['round']<=10 or type(d['flag']) is not int or not 0<=d['flag']<=2:raise ValueError('時刻') if d['ended'] not in ('','lost','defeated','flag','draw'):raise ValueError('結果') if not isinstance(d['hp'],list) or len(d['hp'])!=4 or any(type(h)is not int or not 0<=h<=3 for h in d['hp']):raise ValueError('体力') if not isinstance(d['pos'],list) or len(d['pos'])!=4 or any(not isinstance(p,list) or len(p)!=2 or any(type(v)is not int for v in p) or not inside(p) or tuple(p) in LAYOUT[d['case']][3] for p in d['pos']):raise ValueError('位置') if len({tuple(p) for p,h in zip(d['pos'],d['hp']) if h})!=sum(bool(h) for h in d['hp']):raise ValueError('重なり') if not isinstance(d['events'],list) or any(not isinstance(e,str) or len(e)>100 for e in d['events']):raise ValueError('記録') d=dict(d);d['pos']=tuple(map(tuple,d['pos']));d['hp']=tuple(d['hp']);d['events']=tuple(d['events']);return State(**d) def save(path,s,history): p=Path(path);data={'game':'097','version':VERSION,'case':s.case,'state':asdict(s),'history':[asdict(t) for t in history],'settings':{'variant':s.variant}};tmp=p.with_suffix('.tmp');tmp.write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8');os.replace(tmp,p) def load(path): d=json.loads(Path(path).read_text(encoding='utf-8')) if not isinstance(d,dict) or d.get('game')!='097' or type(d.get('version'))is not int or d['version']!=VERSION:raise ValueError('保存の作品または版が違います') s=unpack(d['state']);h=[unpack(t) for t in d['history']] if len(h)>20 or d['case']!=s.case or d['settings']!={'variant':s.variant} or any(t.case!=s.case or t.variant!=s.variant for t in h):raise ValueError('保存履歴が違います') return s,h KEYS={'SPACE','RETURN','Z','X','P','H','R','M','UP','DOWN','LEFT','RIGHT','TAB','1','2','3','B','F12','ESCAPE','S','L','T'} class Controller: def __init__(self,path='save_097.json'): self.path=path;self.s=new();self.title=True;self.help=False;self.dialog='';self.previous=set();self.gate=False;self.history=[];self.person=0;self.choice=0;self.overlay=0;self.notice='' def reset(self,case=None,variant=None): self.s=new(case or self.s.case,variant or self.s.variant);self.history=[];self.person=0;self.choice=0;self.dialog='';self.notice='' def options(self):return tuple(a for a in legal(self.s) if int(a.split(':')[1])==self.person) def selected(self): opts=self.options();return opts[self.choice%len(opts)] if opts else '' def apply(self,a): if a=='undo': if self.history:self.s=self.history.pop();self.choice=0;return 'undo' return 'ignored:undo' old=self.s;self.s=step(old,a) if self.s==old:return 'ignored:'+a self.history.append(old);self.choice=0 if not self.s.hp[self.person]:self.person=1-self.person return a def feed(self,down): down=set(down)&KEYS;fresh=down-self.previous;self.previous=down if self.gate: if not down:self.gate=False return None if not fresh:return None self.gate=True if len(fresh)>1:return 'ignored:multiple' k=next(iter(fresh));self.notice='' if k=='F12':return 'screenshot' if self.dialog: if k in ('X','ESCAPE'):self.dialog='';return 'cancel' if k=='RETURN': d=self.dialog;self.dialog='' if d=='quit':return 'quit' if d=='retry':self.reset();return 'reset' if d=='menu':self.title=True;return 'menu' return 'ignored:dialog' if k=='ESCAPE':self.dialog='quit';return 'confirm:quit' if k in ('H','P'):self.help=not self.help;return 'help' if self.help:return 'ignored:help' if self.title: if k in ('1','2','3'):self.reset('ABC'[int(k)-1]);return 'case' if k=='B':self.reset(variant='ally' if self.s.variant=='both' else 'both');return 'variant' if k in ('SPACE','RETURN'):self.title=False;return 'start' return 'ignored:title' if k=='M':self.dialog='menu';return 'confirm:menu' if k=='R':self.dialog='retry';return 'confirm:retry' if k=='X':return self.apply('undo') if k=='S': try:save(self.path,self.s,self.history);self.notice='保存しました。' except OSError:self.notice='保存できません。以前の保存は残します。' return 'save' if k=='L': try:self.s,self.history=load(self.path);self.person=0 if self.s.hp[0] else 1;self.choice=0;self.notice='保存を読みました。' except (OSError,ValueError,KeyError,TypeError):self.notice='保存が読めません。今の状態を保持。Rで新規開始。' return 'load' if k=='T':self.overlay=(self.overlay+1)%3;return 'overlay' if self.s.ended:return 'ignored:result' if k in ('LEFT','RIGHT','TAB'):self.person=1-self.person;self.choice=0;return 'select:unit' if k in ('UP','DOWN'):self.choice+=(-1 if k=='UP' else 1);return 'select:action' if k=='RETURN':return self.apply(self.selected()) return 'ignored:key' # Teaching view; original model/controller behavior is separate from rendering. def tap(ui,key): ui.feed(set()) event=ui.feed({key}) ui.feed(set()) return event def commit(ui,action): """Synthetic UI exercise: select an existing option and press/release Enter.""" who=int(action.split(':')[1]) if ui.person != who:tap(ui,'TAB') opts=ui.options() if action not in opts:raise ValueError(action) for _ in range((opts.index(action)-ui.choice)%len(opts)):tap(ui,'DOWN') return tap(ui,'RETURN') class App: def __init__(self,save_path='save_097.json'): self.ui=Controller(save_path) self.event='READY' def prediction(self): if STAGE != 5 or self.ui.s.ended:return None s=self.ui.s; a=self.ui.selected() if not a:return None pred=step(s,a) # Same forecast convention as original app: chosen action, then wait. if not pred.ended and pred.round==s.round:pred=enemy_phase(pred) return pred def draw(self,p): u=self.ui;s=u.s;p.cls(1) p.text(16,12,'MIRROR SKIRMISH / STAGE '+str(STAGE),10) p.text(16,28,'CURRENT STATE / CASE '+s.case+' / '+getattr(s,'variant','training'),7) walls=WALLS if STAGE==1 else LAYOUT[s.case][3] shown=replace(s,equipped=True) if STAGE>=4 and u.overlay==2 else s for y in range(5): for x in range(6): px=16+x*62;py=56+y*62;cell=(x,y) p.rect(px,py,59,59,5 if cell in walls else 2 if (x+y)%2 else 1) p.rectb(px,py,59,59,13 if cell in walls else 5) p.text(px+3,py+3,f'{x},{y}',6) if STAGE>=3 and cell==LAYOUT[s.case][1]:p.text(px+3,py+44,'FLAG',10) if STAGE>=4 and cell==LAYOUT[s.case][2]:p.text(px+3,py+44,'ALTAR',13) if STAGE>=4 and u.overlay: if cell in zone(shown,u.person):p.rectb(px+6,py+8,47,43,7) if any(cell in zone(shown,i) for i in (2,3)):p.rectb(px+10,py+12,39,35,8) elif f'move:{u.person}:{x}:{y}' in legal(s):p.rectb(px+6,py+8,47,43,11) for i,pos in enumerate(s.pos): if not s.hp[i]:continue x=16+pos[0]*62;y=56+pos[1]*62 color=12 if i<2 else 8;p.circ(x+30,y+27,10,color) p.text(x+24,y+25,('A' if i<2 else 'E')+str(i%2+1),7) for n in range(s.hp[i]):p.rect(x+14+n*11,y+52,8,3,11 if i<2 else 9) if i==u.person:p.circb(x+30,y+27,14,10) p.text(410,56,'CURRENT HP '+str(s.hp),11) if STAGE>=2:p.text(410,72,'ACTIONS '+str(s.left)+'/2 ROUND '+str(s.round)+'/10',7) if STAGE>=3:p.text(410,88,'FLAG STREAK '+str(s.flag)+'/2',10) if STAGE>=4:p.text(410,104,'EQUIPPED '+str(s.equipped)+' / '+{'A':'PIKE','B':'HAMMER','C':'DIAGONAL'}[s.case],7) p.text(410,128,'ALLY '+str(u.person+1)+' / SELECT THEN ENTER',12) opts=u.options() for i,a in enumerate(opts): selected=i==u.choice%len(opts) if selected:p.rect(404,145+i*18,302,16,5) p.text(410,150+i*18,('> ' if selected else ' ')+a,10 if selected else 7) if STAGE==5: p.rectb(404,326,302,88,10);p.text(412,335,'FUTURE: CHOSEN ACTION + REST WAIT',10) pred=self.prediction() p.text(412,354,'HP '+str(pred.hp) if pred else ('NO FORECAST: RESULT' if s.ended else 'NO FORECAST: SELECT A LIVING ALLY'),7) if pred:p.text(412,372,'ROUND '+str(pred.round)+' FLAG '+str(pred.flag)+' END '+(pred.ended or '-'),6) p.text(412,396,'THIS BOX DOES NOT CHANGE CURRENT STATE',6) p.text(16,383,('MOVES: GREEN','CURRENT ATTACK: WHITE=ALLY / RED=ENEMY','FUTURE IF EQUIPPED: WHITE=ALLY / RED=ENEMY')[u.overlay] if STAGE>=4 else 'GREEN = LEGAL MOVE / COORDINATES START AT ZERO',10) if s.ended:p.text(16,405,'RESULT: '+s.ended.upper(),10) else:p.text(16,405,['','MOVE ONLY / NO TURNS','STATIC ENEMIES / NO FLAG','UNARMED / ENEMIES + FLAG',('BOTH ARMIES EQUIP AT ONCE' if getattr(s,'variant','both')=='both' else 'COMPARISON: ONLY ALLIES EQUIP'),'X UNDO / S SAVE / L LOAD'][STAGE],6) p.text(16,435,'UP/DOWN ACTION / ENTER COMMIT' if STAGE==1 else 'UP/DOWN ACTION / LEFT/RIGHT/TAB ALLY / ENTER COMMIT',7) p.text(16,452,'R RETRY / M TITLE / H HELP / ESC QUIT: THEN ENTER OR X CANCEL',6) p.text(16,469,'RELEASE EVERY KEY / R RESETS STATE, DOES NOT RELOAD CODE',6) p.text(16,490,'LAST INPUT: '+self.event[:100],10) if u.notice:p.text(410,310,'NOTICE: '+('SAVED' if self.event=='save' and '保存しました' in u.notice else 'LOADED' if self.event=='load' and '保存を読みました' in u.notice else 'IO FAILED / CURRENT STATE KEPT'),10) if u.title or u.help: p.rect(40,65,640,350,1);p.rectb(40,65,640,350,10) lines=['MIRROR SKIRMISH / STAGE '+str(STAGE),'SPACE / ENTER START (RELEASE AFTER PRESS)', 'UP/DOWN SELECT ACTION, ENTER COMMITS ONE ACTION', 'LEFT/RIGHT/TAB SELECT ALLY (STAGE 02+)', '1/2/3 SELECT CASE AT TITLE (STAGE 03+)', 'B: BOTH / ALLY EQUIPMENT AT TITLE (STAGE 04+)', 'T: MOVE / CURRENT REACH / IF EQUIPPED (STAGE 04+)', 'X: UNDO, S: SAVE, L: LOAD (STAGE 05)', 'H OR P: HELP / INPUT PAUSED WHILE HELP IS OPEN', 'R/M/ESC: CONFIRM WITH ENTER, CANCEL WITH X', 'ASCII TEACHING VIEW / SEE JAPANESE LESSON.md', 'CURRENT CASE '+s.case+' / '+getattr(s,'variant','training')] for j,line in enumerate(lines):p.text(58,88+j*25,line,10 if j in (0,11) else 7) if u.dialog: p.rect(80,175,560,120,1);p.rectb(80,175,560,120,8) p.text(104,198,'CONFIRM '+u.dialog.upper(),10) p.text(104,229,'RELEASE ALL KEYS / ENTER YES / X CANCEL',7) p.text(104,260,'R DOES NOT RELOAD MODIFIED PYTHON SOURCE',6) def demo(app): u=app.ui tap(u,'SPACE') if STAGE==1:commit(u,'move:0:0:2') elif STAGE==2:commit(u,'attack:0:2') elif STAGE==3: commit(u,'move:0:0:2');commit(u,'move:0:1:2') else: tap(u,'M');tap(u,'RETURN');tap(u,'2');tap(u,'SPACE') commit(u,'pick:0');commit(u,'move:0:2:1') app.event='SYNTHETIC CHECKPOINT' def main(headless=False,screenshot=None,state_json=None,checkpoint=False): # Save path is also resolved before Pyxel can change the process cwd. save_path=str((Path.cwd()/'save_097.json').resolve()) import pyxel as p app=App(save_path) if checkpoint:demo(app) p.init(720,520,title='Mirror lesson '+str(STAGE),fps=30,display_scale=1,quit_key=p.KEY_NONE,headless=headless) keys={k:getattr(p,'KEY_'+k) for k in KEYS} def update(): if headless and p.frame_count>=5: 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.s),'title':app.ui.title,'scope':'actual Linux headless Pyxel, synthetic controls'},ensure_ascii=False,indent=2)+'\n',encoding='utf-8') p.quit();return e=app.ui.feed({k for k,v in keys.items() if p.btn(v)}) if not headless else None if e: app.event=e if e=='quit':p.quit() p.run(update,lambda:app.draw(p)) if __name__=='__main__': import argparse parser=argparse.ArgumentParser(description='Standalone mirror 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('outputs/checkpoint require --headless-smoke') for field,suffix in (('screenshot','.png'),('state_json','.json')): value=getattr(args,field) if value is not None: target=Path(value).resolve() if target.suffix.lower()!=suffix or not target.parent.is_dir() or target.exists():parser.error('output must be a new '+suffix+' in an existing directory') setattr(args,field,str(target)) main(args.headless_smoke,args.screenshot,args.state_json,args.demo_checkpoint)