"""Pure 30Hz combat, fixed-point tenths of pixels, perception-bounded enemies."""
from dataclasses import dataclass,replace,asdict
from math import hypot
import json,hashlib,heapq
SCALE=10
@dataclass(frozen=True)
class Level:
 title:str; start:tuple; enemies:tuple; wall:tuple|None=None
LEVELS=(Level('横へ、一歩',(300,520),(('spear',940,520),)),Level('槍の向こう',(380,520),(('spear',900,520),('axe',1220,520))),Level('斧のそばで待つ',(300,760),(('axe',820,540),('spear',1170,380)),(550,200,670,470)))
@dataclass(frozen=True)
class Input:
 dx:int=0;dy:int=0;sword:bool=False;dodge:bool=False
@dataclass(frozen=True)
class Observation:
 seen:tuple|None
@dataclass(frozen=True)
class Memory:
 point:tuple|None=None;wait:int=0
@dataclass(frozen=True)
class Intent:
 goal:tuple|None;start:bool;direction:tuple;memory:Memory
@dataclass(frozen=True)
class Enemy:
 id:int;kind:str;x:int;y:int;home:tuple;hp:int=3;stage:str='idle';timer:int=0;direction:tuple=(1000,0);memory:Memory=Memory();attack_id:int=0
@dataclass(frozen=True)
class Attack:
 id:int;owner:int;kind:str;x:int;y:int;direction:tuple;remaining:int=6;hit:tuple=()
@dataclass(frozen=True)
class State:
 x:int;y:int;enemies:tuple;facing:tuple=(1000,0);hp:int=3;invuln:int=0;dash:int=0;dash_cd:int=0;dash_dir:tuple=(1000,0);sword:int=0;sword_dir:tuple=(1000,0);sword_hit:tuple=();prev_sword:bool=False;prev_dodge:bool=False;attacks:tuple=();tick:int=0;serial:int=0;status:str='play';friendly_hits:int=0;sword_hits:int=0;damage_cause:str='';observations:tuple=();resolved_attacks:tuple=()
def initial(level=0):
 b=LEVELS[level] if isinstance(level,int) else level
 return State(*b.start,tuple(Enemy(i,k,x,y,(x,y)) for i,(k,x,y) in enumerate(b.enemies)))
def unit(dx,dy):
 h=hypot(dx,dy)
 return (round(dx/h*1000),round(dy/h*1000)) if h else (1000,0)
def segment_box(a,z,box):
 lo,hi=0.,1.
 for av,dv,mn,mx in ((a[0],z[0]-a[0],box[0],box[2]),(a[1],z[1]-a[1],box[1],box[3])):
  if dv==0:
   if not mn<=av<=mx:return False
  else:
   u,v=(mn-av)/dv,(mx-av)/dv;lo=max(lo,min(u,v));hi=min(hi,max(u,v))
   if lo>hi:return False
 return True
def los(b,a,z):return not b.wall or not segment_box(a,z,b.wall)
def valid(b,x,y,r=50):
 if not 80+r<=x<=1520-r or not 80+r<=y<=960-r:return False
 return not b.wall or not(b.wall[0]-r<x<b.wall[2]+r and b.wall[1]-r<y<b.wall[3]+r)
def move(b,x,y,d,speed):
 xx=x+round(d[0]*speed/1000);yy=y+round(d[1]*speed/1000)
 if valid(b,xx,y):x=xx
 if valid(b,x,yy):y=yy
 return x,y
def waypoint(b,p,target):
 # Visibility graph around one expanded pillar. Only fixed map + memory target.
 if not b.wall:return target
 x1,y1,x2,y2=b.wall;box=(x1-55,y1-55,x2+55,y2+55)
 if not segment_box(p,target,box):return target
 nodes=(p,target,(box[0]-2,box[1]-2),(box[2]+2,box[1]-2),(box[0]-2,box[3]+2),(box[2]+2,box[3]+2))
 q=[(0,0,())];seen=set()
 while q:
  cost,i,route=heapq.heappop(q)
  if i in seen:continue
  seen.add(i)
  if i==1:return nodes[route[0]] if route else target
  for j,z in enumerate(nodes):
   if j!=i and j not in seen and valid(b,*z) and not segment_box(nodes[i],z,box):heapq.heappush(q,(cost+hypot(z[0]-nodes[i][0],z[1]-nodes[i][1]),j,route+(j,)))
 return p
def observe(b,e,player):return Observation(player if hypot(player[0]-e.x,player[1]-e.y)<=700 and los(b,(e.x,e.y),player) else None)
def policy(o,m,e):
 # No true player/world/future-input parameter. Active attacks do not observe.
 if e.stage!='idle' or e.hp<=0:return Intent(None,False,e.direction,m)
 if o.seen is not None:
  dist=hypot(o.seen[0]-e.x,o.seen[1]-e.y);reach=600 if e.kind=='spear' else 370
  return Intent(o.seen,dist<=reach,unit(o.seen[0]-e.x,o.seen[1]-e.y),Memory(o.seen,12))
 if m.point is not None:
  if hypot(m.point[0]-e.x,m.point[1]-e.y)>10:return Intent(m.point,False,e.direction,m)
  if m.wait>0:return Intent(None,False,e.direction,replace(m,wait=m.wait-1))
 return Intent(e.home,False,e.direction,Memory())
def region_hit(kind,origin,d,p,r=50):
 dx,dy=p[0]-origin[0],p[1]-origin[1]
 if kind=='axe':return dx*dx+dy*dy<=(290+r)**2
 along=(dx*d[0]+dy*d[1])/1000;side=abs(dx*d[1]-dy*d[0])/1000
 length,half=(520,70) if kind=='spear' else (180,100)
 outside_x=max(-along,0,along-length);outside_y=max(0,side-half)
 return outside_x*outside_x+outside_y*outside_y<=r*r
def step(b,s,inp=Input(),friend_damage=2):
 if s.status!='play':return s
 # Enemy observations are all made from the old tick, before any input movement.
 enemies=[];obs=[];serial=s.serial;new_attacks=[]
 for e in s.enemies:
  o=observe(b,e,(s.x,s.y)) if e.stage=='idle' and e.hp>0 else Observation(None);obs.append(o)
  it=policy(o,e.memory,e)
  if e.hp<=0:enemies.append(e);continue
  if e.stage=='idle':
   if it.start:
    serial+=1;e=replace(e,stage='windup',timer=36 if e.kind=='spear' else 42,direction=it.direction,memory=it.memory,attack_id=serial)
   else:
    xx,yy=e.x,e.y
    if it.goal is not None:
     dest=waypoint(b,(e.x,e.y),it.goal)
     if hypot(dest[0]-e.x,dest[1]-e.y)>6:xx,yy=move(b,e.x,e.y,unit(dest[0]-e.x,dest[1]-e.y),6)
    e=replace(e,x=xx,y=yy,memory=it.memory)
  else:
   timer=e.timer-1
   if timer<=0:
    if e.stage=='windup':
     new_attacks.append(Attack(e.attack_id,e.id,e.kind,e.x,e.y,e.direction));e=replace(e,stage='active',timer=6)
    elif e.stage=='active':e=replace(e,stage='recover',timer=42)
    else:e=replace(e,stage='idle',timer=0)
   else:e=replace(e,timer=timer)
  enemies.append(e)
 dx=max(-1,min(1,inp.dx));dy=max(-1,min(1,inp.dy));direction=unit(dx,dy) if dx or dy else s.facing
 facing=direction if dx or dy else s.facing;dash=max(0,s.dash-1);cd=max(0,s.dash_cd-1);sw=max(0,s.sword-1);dd=s.dash_dir;sd=s.sword_dir;sh=s.sword_hit
 # Sword wins a simultaneous fresh sword+dodge press. Neither auto-repeats.
 if inp.sword and not s.prev_sword and not s.sword and not s.dash:sw=24;sd=facing;sh=()
 elif inp.dodge and not s.prev_dodge and not s.sword and not s.dash and not s.dash_cd:dash=12;cd=30;dd=direction
 speed=28 if dash else 12 if sw else 15
 x,y=move(b,s.x,s.y,dd if dash else direction,speed) if dash or dx or dy else (s.x,s.y)
 damage=[0]*len(enemies);hurt=0;cause=s.damage_cause;fh=s.friendly_hits;swh=s.sword_hits
 # Snapshot old HP for simultaneous application. Enemy sword hit does not cancel windup.
 if 13<=sw<=18:
  for e in enemies:
   if e.hp>0 and e.id not in sh and region_hit('sword',(x,y),sd,(e.x,e.y)) and los(b,(x,y),(e.x,e.y)):damage[e.id]+=1;sh+=e.id,;swh+=1
 attacks=[];resolved=[]
 for a in s.attacks+tuple(new_attacks):
  hit=a.hit
  if -1 not in hit and region_hit(a.kind,(a.x,a.y),a.direction,(x,y)) and los(b,(a.x,a.y),(x,y)):
   hit+=(-1,)
   if s.invuln==0 and not hurt:hurt=1;cause=f'{a.kind}#{a.id}'
  for e in enemies:
   if e.hp>0 and e.id!=a.owner and e.id not in hit and region_hit(a.kind,(a.x,a.y),a.direction,(e.x,e.y)) and los(b,(a.x,a.y),(e.x,e.y)):
    hit+=(e.id,);damage[e.id]+=friend_damage
    if friend_damage:fh+=1
  resolved.append(replace(a,hit=hit))
  if a.remaining>1:attacks.append(replace(a,remaining=a.remaining-1,hit=hit))
 enemies=tuple(replace(e,hp=max(0,e.hp-damage[e.id]),stage='dead' if e.hp-damage[e.id]<=0 else e.stage) for e in enemies)
 hp=max(0,s.hp-hurt);status='lost' if hp==0 else 'won' if all(e.hp==0 for e in enemies) else 'play'
 return State(x,y,enemies,facing,hp,30 if hurt else max(0,s.invuln-1),dash,cd,dd,sw,sd,sh,inp.sword,inp.dodge,tuple(attacks),s.tick+1,serial,status,fh,swh,cause,tuple(obs),tuple(resolved))
def state_hash(s):return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,separators=(',',':')).encode('utf-8')).hexdigest()
