# 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 穴だらけの防衛線 / 第5段階。LESSON.md を参照。""" STAGE=5 STAGE_NOTE='UNDO RESTORES EVERY FIELD / INVALID PLANS DO NOT ADD HISTORY' """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' @dataclass(frozen=True) class Timeline: state:State history:tuple=() REASONS={'ready':'同じ分岐にいる全員が、同じ道を通ります。','ok':'計画を確定しました。','funds':'資材不足。今刻の落荷は、次の刻から使えます。','occupied':'砲座があります。先に解体してください。','empty':'その場所には砲座がありません。','invalid':'この計画は選べません。','terminal':'結果が出ました。Xで戻すかRで再開。','undo':'直前の計画に戻しました。','no_undo':'まだ戻せる計画がありません。'} BUILDS=('none','F1B','F1H','F2B','F2H','D1','D2') 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] not in BUILDS:return s,'invalid' route,build=a;towers=list(s.towers);cd=list(s.cooldown);cash=s.material;spent=s.spent;reclaim=s.reclaimed if build.startswith('F'): i=int(build[1])-1 if towers[i]:return s,'occupied' if cash<2:return s,'funds' towers[i]=build[2];cash-=2;spent+=2;cd[i]=0 elif build.startswith('D'): i=int(build[1])-1 if not towers[i]:return s,'empty' towers[i]='';cd[i]=0;cash+=1;reclaim+=1 cd=[max(0,v-1) for v in cd] 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] dead=set();killed=s.killed for i,kind in enumerate(towers): if not kind or cd[i]:continue targets=sorted((u for u in moved if u.cell=='F'+str(i+1) and u.kind!='T' and (kind=='H' or u.kind=='R')),key=lambda u:(u.spawn,u.id)) if targets:dead.add(targets[0].id);killed+=1;cd[i]=2 if kind=='H' else 0 units=[];hp=s.hp;salvage=s.salvage;escaped=s.escaped;cars=s.cars for u in moved: if u.id in dead:continue if u.kind=='T' and u.cell=='S2' and not u.dropped: salvage+=salvage_value;cash+=salvage_value;u=replace(u,dropped=True) 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) ns=State(t,tuple(units),tuple(towers),tuple(cd),cash,hp,salvage,reclaim,spent,killed,escaped,cars,route,('won' if hp>=1 else 'lost') if done else 'playing') return ns,'ok' def step(tl,l,a,salvage_value=2): if a==('undo',): return (Timeline(tl.history[-1],tl.history[:-1]),'undo') if tl.history else (tl,'no_undo') ns,r=apply(tl.state,l,a,salvage_value) return (Timeline(ns,tl.history+(tl.state,)),r) if ns!=tl.state else (tl,r) def forecast(s,l,a,salvage_value=2):return apply(s,l,a,salvage_value) 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 self.timeline = Timeline(self.state) @property def choices(self): return BUILDS def commit(self, action): self.timeline, self.reason = step(self.timeline, self.board, action, self.salvage_value) self.state = self.timeline.state 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 if 'B' in pressed: self.salvage_value = 0 if self.salvage_value else 2 self.reset() self.gate = True return if 'X' in pressed: self.commit(('undo',)) self.gate = True return 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,'SALVAGE VALUE '+str(self.salvage_value)+' / B TOGGLE + RESET',11) n,r=forecast(s,self.board,(self.route,self.choices[self.selection]),self.salvage_value) p.text(12,355,'NEXT '+r.upper()+' / HP '+str(n.hp)+' / CASH '+str(n.material)+' / KILLS +'+str(n.killed-s.killed),10) p.text(450,337,'X UNDO / HISTORY '+str(len(self.timeline.history)),6) 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','DOWN','DOWN','SPACE','SPACE','SPACE','SPACE','X') 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