# MIT License # # Copyright (c) 2026 OpenAI-assisted game contributors # # 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. # # This MIT license covers original source, documentation and primitive artwork. # Bitmap glyph data is derived from Noto Sans CJK and remains under SIL Open # Font License 1.1. See assets/NOTO-LICENSE.txt for the separate font license. """045 穴だらけの防衛線 / 第1段階。LESSON.md を参照。""" STAGE=1 STAGE_NOTE='MOVING ONE CELL DOES NOT MEAN CHOOSING ONE UNIT' """Immutable, deterministic cohort-routing defense. No Pyxel dependency.""" from dataclasses import dataclass,replace,asdict import hashlib,json @dataclass(frozen=True) class Level: title:str waves:tuple LEVELS=(Level('別々に来る',((0,('R',)),(2,('T',)),(5,('A',)),(7,('R',)))),Level('一緒に来た荷車',((0,('T','R')),(3,('A',)),(4,('R',)),(5,('R',)))),Level('資材より今の列',((0,('T',)),(3,('R','R')),(5,('T','A')),(7,('A',)),(8,('R',))))) @dataclass(frozen=True) class Unit: id:int kind:str cell:str spawn:int dropped:bool=False @dataclass(frozen=True) class State: t:int=0 units:tuple=() towers:tuple=('', '') cooldown:tuple=(0,0) material:int=2 hp:int=3 salvage:int=0 reclaimed:int=0 spent:int=0 killed:int=0 escaped:int=0 cars:int=0 route:str='F' outcome:str='playing' REASONS={'ready':'同じ分岐にいる全員が、同じ道を通ります。','ok':'計画を確定しました。','funds':'資材不足。今刻の落荷は、次の刻から使えます。','occupied':'砲座があります。先に解体してください。','empty':'その場所には砲座がありません。','invalid':'この計画は選べません。','terminal':'結果が出ました。Xで戻すかRで再開。','undo':'直前の計画に戻しました。','no_undo':'まだ戻せる計画がありません。'} BUILDS=('none',) def scheduled(l,t): result=[];uid=0 for at,kinds in l.waves: for k in kinds: if at==t:result.append(Unit(uid,k,'E',at)) uid+=1 return tuple(result) def initial(l):return State(units=scheduled(l,0)) def apply(s,l,a,salvage_value=2): if s.outcome!='playing':return s,'terminal' if len(a)!=2 or a[0] not in ('F','S') or a[1]!='none':return s,'invalid' route,build=a path={'E':'J','J':route+'1','F1':'F2','S1':'S2','F2':'EXIT','S2':'EXIT'} moved=[replace(u,cell=path[u.cell]) for u in s.units] units=[];hp=s.hp;escaped=s.escaped;cars=s.cars for u in moved: if u.cell=='EXIT': if u.kind=='T':cars+=1 else:hp=max(0,hp-1);escaped+=1 else:units.append(u) t=s.t+1;units+=scheduled(l,t) done=not units and t>=max(at for at,_ in l.waves) return replace(s,t=t,units=tuple(units),hp=hp,escaped=escaped,cars=cars,route=route,outcome=('won' if hp>=1 else 'lost') if done else 'playing'),'ok' def invariant(s,l): assert s.material==2+s.salvage+s.reclaimed-s.spent and s.material>=0 assert s.hp==max(0,3-s.escaped) assert len({u.id for u in s.units})==len(s.units) spawned=sum(len(k) for t,k in l.waves if t<=s.t) assert spawned==len(s.units)+s.killed+s.escaped+s.cars assert all(u.cell in ('E','J','F1','F2','S1','S2') for u in s.units) assert all(v in (0,1,2) for v in s.cooldown) assert s.salvage>=0 def state_hash(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True).encode('utf-8')).hexdigest() class App: def __init__(self): self.level = 0 self.salvage_value = 2 self.reset() @property def board(self): return LEVELS[self.level] def reset(self): self.state = initial(self.board) self.route = 'F' self.selection = 0 self.reason = 'ready' self.gate = False pass @property def choices(self): return BUILDS def commit(self, action): self.state, self.reason = apply(self.state, self.board, action, self.salvage_value) if self.reason == 'ok': self.selection = 0 def control(self, pressed, held): if self.gate: if not held: self.gate = False return if 'R' in pressed: self.reset() self.gate = True return for i, key in enumerate(('1', '2', '3')): if key in pressed: self.level = i self.reset() self.gate = True return pass if 'LEFT' in pressed: self.route = 'F' if 'RIGHT' in pressed: self.route = 'S' if 'UP' in pressed: self.selection = (self.selection - 1) % len(self.choices) if 'DOWN' in pressed: self.selection = (self.selection + 1) % len(self.choices) if 'SPACE' in pressed or 'Z' in pressed: self.commit((self.route, self.choices[self.selection])) if pressed: self.gate = True def draw(self, p): s = self.state p.cls(1) p.text(12,10,'045 OPEN DEFENSE / LESSON '+str(STAGE),10) p.text(12,28,'LEVEL '+'ABC'[self.level]+' / TICK '+str(s.t)+' / HP '+str(s.hp)+' / '+s.outcome.upper(),7) p.text(370,28,'CASH '+str(s.material)+' SALVAGE '+str(s.salvage),11) p.text(12,48,'R RUNNER / A ARMOR / T TRANSPORT (HARMLESS)',6) pos={'E':(40,140),'J':(145,140),'F1':(285,95),'F2':(425,95),'S1':(285,210),'S2':(425,210),'EXIT':(570,140)} for a,b in (('E','J'),('J','F1'),('F1','F2'),('F2','EXIT'),('J','S1'),('S1','S2'),('S2','EXIT')): x,y=pos[a];xx,yy=pos[b] p.line(x+20,y+12,xx+20,yy+12,5) for cell,(x,y) in pos.items(): p.rect(x,y,48,24,4 if cell.startswith('F') else 3 if cell.startswith('S') else 5) p.rectb(x,y,48,24,6) p.text(x+7,y+9,cell,7) for j,u in enumerate(v for v in s.units if v.cell==cell): p.circ(x+7+j*16,y+35,6,10 if u.kind=='T' else 8) p.text(x+5+j*16,y+33,u.kind,1) for i,x in enumerate((285,425)): p.text(x-6,76,'TOWER '+(s.towers[i] or '-')+' CD '+str(s.cooldown[i]),10) p.text(240,270,'ONLY J COHORT OBEYS THIS TICK: '+self.route,10) p.text(12,293,'PLAN '+self.route+' / '+self.choices[self.selection]+' / RESULT '+self.reason.upper(),7) p.text(12,311,'KILLED '+str(s.killed)+' / ESCAPED '+str(s.escaped)+' / CARS '+str(s.cars),6) p.text(12,337,'MOVEMENT ONLY: NO TOWER, SALVAGE, FORECAST OR UNDO',11) p.text(12,389,'WAVES '+str(self.board.waves),13) p.text(12,407,'ARROWS ROUTE / BUILD Z OR SPACE COMMIT ONE TICK',7) p.text(12,425,'1-3 LEVEL / R RESET / ESC QUIT / RELEASE EACH KEY',6) p.text(12,449,STAGE_NOTE,10) def demo_keys(): return ('2','SPACE','RIGHT','SPACE','SPACE','SPACE') def main(headless=False, screenshot=None, report=None, demo=False): import pyxel as p p.init(700,470,title='Open defense lesson '+str(STAGE),fps=30,display_scale=1,headless=headless) app=App() sequence=demo_keys() if demo else () limit=max(12,2*len(sequence)+4) keys={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','SPACE','Z','X','R','1','2','3','B')} 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) if report: from pathlib import Path Path(report).write_text(json.dumps({'stage':STAGE,'state':asdict(app.state),'reason':app.reason,'scope':'actual Pyxel; synthetic key input; not native keyboard or human playtest'},ensure_ascii=False,indent=2),encoding='utf-8') p.quit() return if headless: index=p.frame_count//2 pressed={sequence[index]} if p.frame_count%2==0 and index