Files
counterstrikemanager/js/live.js
T
deepseek 4c7fe1db4c Counter-Strike esports manager: career mode + LIVE tactical top-down simulation
- Full manager sim: 28 orgs / 168 players, calendar, transfers, youth scouting, loans, training, board confidence
- Simulation Mode with real-time micro-engine (live.js): A* pathfinding on map geometry, raycast ballistics (spread/falloff/armor/headshots), smokes/flashes/HE that matter, plant/defuse/retake AI, clutch detection
- Canvas renderer (viz.js): follow/radar cameras, HP bars, CS2-style kill feed, callouts, synthesized Web-Audio SFX, speed x1-x3, AUTO-SPECTATE hands-free series playback
- Round results flow from the firefight back into the career engine (score, economy, box score, MVP)
- Playwright + Node VM test suites included (test/, plus external pwtest harness)
2026-08-23 07:00:47 +00:00

709 lines
26 KiB
JavaScript

/* ============================================================
LIVE — real-time tactical micro-simulation (top-down CS)
A* pathfinding on real geometry, raycast bullets with spread/
falloff/armor/headshots, utility that matters (smoke blocks
LOS, flash blinds, HE/molly damage), plant/defuse/timer.
The round outcome comes OUT of this fight and is fed back
into the career engine by SimSession.finishLiveRound().
============================================================ */
'use strict';
const LIVE=(()=>{
function VZ(){return (typeof window!=='undefined')?window.VIZ:null;}
/* ---------------- maps ---------------- */
const MAPS={
'Dust II':{
pal:{floor:'#182234',wall:'#2c3c5a',crate:'#3b4f73'},
/* lanes separated by real walls: long | mid | tunnels,
joined only at doors / catwalk-stairs / site mouths */
floors:[[12,66,14,10], // T spawn
[26,60,32,8], // outside long (low)
[58,58,4,6], // doors
[62,58,36,8], // along long bottom
[96,16,10,42], // long A (vertical)
[96,6,26,20], // A site
[56,30,40,6], // catwalk (mid->short)
[88,25,8,5], // short stairs
[46,36,9,30], // mid
[40,62,8,6], // tspawn->mid connector
[55,18,41,7], // top-mid (CT side)
[20,46,18,8], // lower tunnel
[16,22,10,24], // upper tunnel
[24,54,8,6], // tspawn->tunnels
[6,6,28,20], // B site
[30,6,72,8], // CT rotate corridor
[110,6,14,14]], // CT spawn
crates:[[101,10],[106,17],[113,12],[119,21],[13,11],[21,16],[27,9],
[51,45],[86,32],[100,44],[120,32]],
tSpawn:[18,71],ctSpawn:[117,11],
sites:{A:[108,15],B:[18,15]},
chokes:{A:[[99,26]],B:[[20,27]]},
holds:{A:[[104,10,'r'],[114,21,'d'],[98,11,'l'],[103,23,'dr']],
B:[[12,9,'l'],[24,19,'d'],[8,20,'dl'],[30,10,'ld']],
mid:[[60,22,'d'],[49,40,'u']]},
flashes:{A:[104,18],B:[17,16]},
},
'Mirage':{
pal:{floor:'#1b2030',wall:'#37304a',crate:'#4a4266'},
floors:[[10,64,16,12],[28,58,30,12],[58,54,10,10],[68,56,34,10],
[100,16,14,42],[96,6,26,20],[52,26,46,8],[44,30,12,36],
[58,16,40,10],[16,42,20,10],[14,18,12,26],[6,6,28,20],
[32,6,68,8],[42,62,10,6],[110,6,14,14]],
crates:[[102,11],[107,17],[112,13],[99,21],[13,11],[21,15],[27,10],
[60,30],[47,50],[104,42],[86,60]],
tSpawn:[17,71],ctSpawn:[117,11],
sites:{A:[109,15],B:[19,15]},
chokes:{A:[[99,27]],B:[[20,27]]},
holds:{A:[[107,10,'r'],[114,21,'d'],[97,13,'l'],[102,23,'dr']],
B:[[11,9,'l'],[25,17,'d'],[7,19,'dl'],[31,11,'ld']],
mid:[[63,21,'d'],[49,33,'u']]},
flashes:{A:[104,17],B:[16,15]},
},
'Inferno':{
pal:{floor:'#20191b',wall:'#4a3038',crate:'#6b4630'},
floors:[[10,62,16,12],[28,58,26,10],[54,52,14,10],[68,50,16,10],
[84,48,20,12],[96,44,24,16],[88,10,14,36],[90,6,28,18],
[46,30,12,26],[58,24,34,8],[16,40,14,22],[14,16,12,26],
[6,6,26,20],[32,6,60,8],[110,24,12,22],[40,60,10,6]],
crates:[[95,11],[101,17],[107,12],[93,20],[12,11],[20,15],[26,10],
[52,33],[62,27],[100,51],[116,52]],
tSpawn:[17,69],ctSpawn:[116,11],
sites:{A:[104,14],B:[106,53]},
chokes:{A:[[92,23]],B:[[92,53]]},
holds:{A:[[101,9,'r'],[111,20,'d'],[92,12,'l'],[98,20,'dr']],
B:[[112,47,'d'],[99,57,'l'],[114,57,'dl'],[96,46,'lu']],
mid:[[62,27,'d'],[50,34,'u']]},
flashes:{A:[98,16],B:[100,55]},
},
'Nuke':{
pal:{floor:'#161e2c',wall:'#33415c',crate:'#465a80'},
floors:[[12,34,18,12],[30,30,18,20],[48,26,44,28],[92,30,26,20],
[56,10,24,16],[56,54,24,16],[100,10,20,16],[100,54,20,16],
[30,12,26,10],[30,58,26,10],[12,12,18,14],[12,54,18,14]],
crates:[[62,14],[71,14],[62,60],[71,60],[52,33],[84,45],[104,16],
[110,61],[36,35]],
tSpawn:[20,40],ctSpawn:[112,40],
sites:{A:[110,17],B:[110,62]},
chokes:{A:[[96,18]],B:[[96,62]]},
holds:{A:[[106,12,'r'],[116,22,'d'],[98,14,'l']],
B:[[106,58,'r'],[116,52,'u'],[98,65,'l']],
mid:[[88,38,'l'],[52,40,'r']]},
flashes:{A:[106,18],B:[106,62]},
},
'Ancient':{
pal:{floor:'#141d22',wall:'#2c4a44',crate:'#3d6459'},
floors:[[12,36,16,10],[28,30,18,22],[46,26,40,28],[86,30,24,20],
[58,8,22,18],[58,54,22,18],[98,8,22,18],[98,54,22,18],
[28,10,28,10],[28,60,28,10]],
crates:[[64,12],[73,12],[64,60],[73,60],[50,31],[82,45],[102,12],
[108,62],[34,35]],
tSpawn:[19,41],ctSpawn:[111,41],
sites:{A:[109,16],B:[109,62]},
chokes:{A:[[94,17]],B:[[94,62]]},
holds:{A:[[105,11,'r'],[115,21,'d'],[97,13,'l']],
B:[[105,58,'r'],[115,51,'u'],[97,66,'l']],
mid:[[82,39,'l'],[50,40,'r']]},
flashes:{A:[104,17],B:[104,61]},
},
};
MAPS['Anubis']=JSON.parse(JSON.stringify(MAPS['Mirage']));
MAPS['Train']=JSON.parse(JSON.stringify(MAPS['Dust II']));
MAPS['Overpass']=JSON.parse(JSON.stringify(MAPS['Ancient']));
MAPS['Vertigo']=JSON.parse(JSON.stringify(MAPS['Nuke']));
/* ---------------- grid / nav ---------------- */
const GW=130,GH=80;
function buildGrid(L){
const g=new Uint8Array(GW*GH);
L.floors.forEach(([x,y,w,h])=>{
for(let yy=Math.max(0,y|0);yy<Math.min(GH,Math.ceil(y+h));yy++)
for(let xx=Math.max(0,x|0);xx<Math.min(GW,Math.ceil(x+w));xx++)g[yy*GW+xx]=1;
});
(L.crates||[]).forEach(([cx,cy])=>{
for(let yy=(cy-2)|0;yy<=((cy+2)|0);yy++)
for(let xx=(cx-2)|0;xx<=((cx+2)|0);xx++)
if(xx>=0&&yy>=0&&xx<GW&&yy<GH)g[yy*GW+xx]=0;
});
ensureConnected(L,g);
return g;
}
function walkable(g,x,y){x|=0;y|=0;return x>=0&&y>=0&&x<GW&&y<GH&&g[y*GW+x]===1;}
function snap(g,x,y){
if(walkable(g,x,y))return{x:x+0.5,y:y+0.5};
for(let r=1;r<=7;r++){
for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){
if(Math.max(Math.abs(dx),Math.abs(dy))!==r)continue;
const nx=(x|0)+dx,ny=(y|0)+dy;
if(walkable(g,nx,ny))return{x:nx+0.5,y:ny+0.5};
}
}
return{x:x+0.5,y:y+0.5};
}
function astar(g,sx,sy,tx,ty){
sx|=0;sy|=0;tx|=0;ty|=0;
if(!walkable(g,sx,sy)||!walkable(g,tx,ty))return null;
if(sx===tx&&sy===ty)return[{x:tx+0.5,y:ty+0.5}];
const W=GW;
const open=[{i:sy*W+sx,f:0,g:0}];
const gs=new Float32Array(W*GH).fill(Infinity);
const came=new Int32Array(W*GH).fill(-1);
const closed=new Uint8Array(W*GH);
gs[sy*W+sx]=0;
const H=i=>Math.abs(i%W-tx)+Math.abs(((i/W)|0)-ty);
let guard=0;
while(open.length&&guard++<9000){
let bi=0;
for(let i=1;i<open.length;i++)if(open[i].f<open[bi].f)bi=i;
const cur=open.splice(bi,1)[0],ci=cur.i;
if(closed[ci])continue;closed[ci]=1;
const cx=ci%W,cy=(ci/W)|0;
if(cx===tx&&cy===ty){
const path=[];let i=ci;
while(i!==-1&&i!==sy*W+sx){path.push({x:i%W+0.5,y:((i/W)|0)+0.5});i=came[i];}
path.push({x:sx+0.5,y:sy+0.5});path.reverse();
// string-pull lite: drop intermediate nodes with clear LOS
const sm=[path[0]];let j=0;
for(let k=2;k<path.length;k++){
if(!losClear(g,path[j].x,path[j].y,path[k].x,path[k].y)){sm.push(path[k-1]);j=k-1;}
}
sm.push(path[path.length-1]);
return sm;
}
for(const[dx,dy]of[[1,0],[-1,0],[0,1],[0,-1]]){
const nx=cx+dx,ny=cy+dy;
if(!walkable(g,nx,ny))continue;
const ni=ny*W+nx,ng=cur.g+1;
if(ng<gs[ni]){gs[ni]=ng;came[ni]=ci;open.push({i:ni,g:ng,f:ng+H(ni)});}
}
}
return null;
}
function losClear(g,x0,y0,x1,y1){
const dx=x1-x0,dy=y1-y0,d=Math.hypot(dx,dy);
if(d<0.01)return true;
return !raycast(g,x0,y0,dx/d,dy/d,d,null).hit;
}
function raycast(g,x0,y0,dx,dy,maxD,smokes){
let x=x0,y=y0;const stp=0.45;
for(let d=0;d<maxD;d+=stp){
x+=dx*stp;y+=dy*stp;
if(!walkable(g,x,y))return{x:x-dx*stp,y:y-dy*stp,hit:true,d};
if(smokes)for(let i=0;i<smokes.length;i++){
const s=smokes[i];
if(s.life>0.12&&s.life<0.9){
const ex=x-s.x,ey=y-s.y;
if(ex*ex+ey*ey<s.r*s.r)return{x,y,hit:false,smoke:true,d};
}
}
}
return{x,y,hit:false,d:maxD};
}
/* guarantee every spawn can reach both sites: carve corridors if needed */
function ensureConnected(L,g){
[L.tSpawn,L.ctSpawn].forEach(sp=>{
if(!walkable(g,sp[0],sp[1])){const s=snap(g,sp[0],sp[1]);sp[0]=s.x;sp[1]=s.y;}
const targets=[L.sites.A,L.sites.B]
.concat(L.chokes.A||[],L.chokes.B||[]);
targets.forEach(tg=>{
if(walkable(g,tg[0],tg[1])&&astar(g,sp[0],sp[1],tg[0],tg[1]))return;
const put=(x,y)=>{
[[0,0],[1,0],[-1,0],[0,1],[0,-1]].forEach(([dx,dy])=>{
const nx=x+dx,ny=y+dy;
if(nx>=1&&ny>=1&&nx<GW-1&&ny<GH-1)g[ny*GW+nx]=1;
});
};
let x=sp[0]|0,y=sp[1]|0;
const tx=tg[0]|0,ty=tg[1]|0;
while(x!==tx){x+=tx>x?1:-1;put(x,y);}
while(y!==ty){y+=ty>y?1:-1;put(x,y);}
tg[0]=tx+0.5;tg[1]=ty+0.5;
L.carved=L.carved||[];
L.carved.push([Math.min(sp[0],tx)-1,Math.min(sp[1],ty)-1,
Math.abs(tx-sp[0])+3,Math.abs(ty-sp[1])+3]);
});
});
}
/* ---------------- weapons ---------------- */
const WEAPONS={
awp:{name:'AWP ⭐',dmg:115,rpm:45,rng:60,spread:0.015,move:0.7,big:true},
rifle:{name:'Rifle',dmg:37,rpm:600,rng:38,spread:0.042,move:0.9},
smg:{name:'SMG',dmg:28,rpm:780,rng:24,spread:0.06,move:1.0},
pistol:{name:'Pistol',dmg:32,rpm:400,rng:20,spread:0.05,move:1.0},
};
/* ---------------- round construction ---------------- */
function makeRound(cfg){
const L=MAPS[cfg.map]||MAPS['Mirage'];
L.w=L.w||130;L.h=L.h||80;
const g=buildGrid(L);
const tKey=cfg.ctSide==='a'?'b':'a';
const mk=(pid,side)=>{
const p=cfg.players[pid],sk=cfg.skills[pid];
const isCT=side===cfg.ctSide;
const sp=isCT?L.ctSpawn:L.tSpawn;
const p0=snap(g,sp[0]+(Math.random()-0.5)*6,sp[1]+(Math.random()-0.5)*6);
return{pid,nick:p.nick,role:p.role,side,isCT,
x:p0.x,y:p0.y,
hp:100,armor:cfg.buys[side]==='full'?100:cfg.buys[side]==='force'?60:0,
dead:false,ang:isCT?-Math.PI/2:Math.PI/2,
spd:(0.82+sk.spd*0.0035),
react:U.clamp(0.46-sk.aim*0.0026-sk.clutch*0.0007,0.13,0.5)*(cfg.timeoutSide===side?0.82:1),
acc:U.clamp(sk.aim/100,0.3,1),
weapon:(cfg.buys[side]==='full')
?(p.role==='AWP'?WEAPONS.awp:WEAPONS.rifle)
:(cfg.buys[side]==='force'?WEAPONS.smg:WEAPONS.pistol),
cool:0,blind:0,target:null,lastSeenT:-9,
path:null,pathI:0,goal:null,fake:false,alertT:-9,
holdNear:null,kills:0,dmg:0,bob:Math.random()*6.28,moving:false,
reactT:null};
};
const agents=[];
cfg.lineups.a.forEach(pid=>agents.push(mk(pid,'a')));
cfg.lineups.b.forEach(pid=>agents.push(mk(pid,'b')));
// CT setup: split holds (2-2-1 default)
const cts=agents.filter(a=>a.isCT);
const split=Math.random()<0.75?[2,2,1]:Math.random()<0.5?[3,1,1]:[1,3,1];
let ci=0;
['A','B','mid'].forEach((setName,si)=>{
const hs=L.holds[setName];
for(let i=0;i<split[si]&&ci<cts.length;i++,ci++){
const a=cts[ci],h=hs[(ci+i)%hs.length];
const hp=snap(g,h[0]+(Math.random()-0.5)*2,h[1]+(Math.random()-0.5)*2);
a.x=hp.x;a.y=hp.y;
a.hold=h;a.task='hold';
const fa=h[2];
a.ang=fa==='r'?0:fa==='l'?Math.PI:fa==='u'?-Math.PI/2:fa==='d'?Math.PI/2:
fa==='dr'?Math.PI/4:fa==='dl'?Math.PI*0.75:fa==='lu'?-Math.PI*0.75:-Math.PI/4;
}
});
// attacker plan
const atkTac=cfg.tactic[tKey];
const site=Math.random()<0.5?'A':'B';
const atks=agents.filter(a=>!a.isCT);
const nFake=atkTac==='fake'?2:0;
atks.forEach((a,i)=>{
a.fake=i<nFake;
const tgt=a.fake?L.sites[site==='A'?'B':'A']:L.sites[site];
const gp=snap(g,tgt[0],tgt[1]);
a.goal={x:gp.x,y:gp.y};
});
const utilPlan=[];
if(atkTac==='fake'){
const o=site==='A'?'B':'A';
L.chokes[o].forEach(([x,y])=>utilPlan.push({t:7,type:'smoke',x,y}));
}else{
L.chokes[site].forEach(([x,y],i)=>utilPlan.push({t:5+i*1.6,type:'smoke',x,y}));
const fl=L.flashes[site];
utilPlan.push({t:8,type:'flash',x:fl[0],y:fl[1]});
}
return {cfg,L,g,agents,site,tKey,t:0,timeLeft:115,
planted:false,plantP:0,bombPos:null,bombT:0,
bombCarrier:atks[Math.floor(Math.random()*atks.length)],
defuseP:0,defuser:null,
smokes:[],fires:[],nades:[],fx:[],corpses:[],feed:[],
callout:null,calloutT:-9,
winner:null,reason:null,killsLog:[],
pain:0,shake:0,ended:false,endT:0,
firstBlood:false,clutchFlag:false,
utilPlan,utilDone:new Set(),lastFlashAt:-9};
}
/* ---------------- utility ---------------- */
function throwNade(st,type,x,y){st.nades.push({type,x,y,fuse:type==='flash'?0.7:type==='he'?1.0:1.1,total:1.1,progress:0,z:0});}
function detonate(st,n){
if(n.type==='smoke'){st.smokes.push({x:n.x,y:n.y,r:12.5,life:0});}
else if(n.type==='flash'){
st.fx.push({type:'flashPop',t0:st.t,dur:0.09});
st.agents.forEach(a=>{
if(a.dead)return;
const d=Math.hypot(a.x-n.x,a.y-n.y);
if(d<16&&!raycast(st.g,n.x,n.y,(a.x-n.x)/d,(a.y-n.y)/d,d,null).hit)
a.blind=Math.max(a.blind,U.clamp(2.5-d*0.1,0.4,2.5));
});
const vz=VZ();if(vz)vz.sFlash();
}else if(n.type==='he'){
st.fx.push({type:'boom',x:n.x,y:n.y,t0:st.t,dur:0.35});
st.shake=Math.min(st.shake+1.7,3);
const vz=VZ();if(vz)vz.sBoom();
st.agents.forEach(a=>{
if(a.dead)return;
const d=Math.hypot(a.x-n.x,a.y-n.y);
if(d<11)a.hp-=Math.max(0,55*(1-d/11));
});
}
}
/* ---------------- helpers ---------------- */
function visAnyEnemy(st,a){
for(const e of st.agents){
if(e.dead||e.isCT===a.isCT)continue;
if(los(st.g,a,e,st.smokes))return true;
}
return false;
}
function los(g,a,b,smokes){
const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy);
if(d<0.01)return true;
const r=raycast(g,a.x,a.y,dx/d,dy/d,d,smokes);
return !r.hit&&!r.smoke;
}
function hotSite(st){
let ht=-9,hx=0,hy=0;
const A=st.L.sites.A,B=st.L.sites.B;
st.agents.forEach(m=>{
if(m.lastSeenT>ht){ht=m.lastSeenT;hx=m.seenX;hy=m.seenY;}
});
if(ht<=0)return null;
// contact older than 25s stops drawing rotations
if(st.t-ht>25)return null;
return Math.hypot(hx-A[0],hy-A[1])<Math.hypot(hx-B[0],hy-B[1])?'A':'B';
}
function holdSideOf(st,a){
const A=st.L.sites.A,B=st.L.sites.B;
return Math.hypot(a.x-A[0],a.y-A[1])<Math.hypot(a.x-B[0],a.y-B[1])?'A':'B';
}
/* ---------------- movement ---------------- */
function moveAlong(st,a,to,dt,noSmooth){
const g=st.g;
let to2=to;
if(a.via){
if(Math.hypot(a.via.x-a.x,a.via.y-a.y)<1.6)a.via=null;
else to2=a.via;
}
const last=a.path&&a.path[a.path.length-1];
const needNew=!a.path||a.pathI>=a.path.length||
Math.hypot(last.x-to2.x,last.y-to2.y)>2.5;
if(needNew){
a.path=astar(g,a.x,a.y,to2.x,to2.y)||[{x:to2.x,y:to2.y}];
a.pathI=0;
}
let tgt=a.path[Math.min(a.pathI,a.path.length-1)];
if(Math.hypot(tgt.x-a.x,tgt.y-a.y)<0.9&&a.pathI<a.path.length-1){a.pathI++;tgt=a.path[a.pathI];}
const dx=tgt.x-a.x,dy=tgt.y-a.y,d=Math.hypot(dx,dy);
if(d>0.12){
const sp=a.spd*3*dt;
const nx=a.x+dx/d*sp,ny=a.y+dy/d*sp;
let moved=false;
if(walkable(g,nx,ny)){a.x=nx;a.y=ny;moved=true;}
else if(walkable(g,nx,a.y)){a.x=nx;moved=true;} // slide X
else if(walkable(g,a.x,ny)){a.y=ny;moved=true;} // slide Y
if(moved){a._stuck=0;a.ang=Math.atan2(dy,dx);a.moving=true;}
else{
a.moving=false;
a._stuck=(a._stuck||0)+1;
if(a._stuck>5){
a._stuck=0;a.path=null;a.via=null;
for(let rr=3;rr<=10;rr+=3){
const s=snap(g,a.x+(Math.random()-0.5)*rr*2,a.y+(Math.random()-0.5)*rr*2);
if(Math.hypot(s.x-a.x,s.y-a.y)>2){a.via={x:s.x,y:s.y};break;}
}
}
}
}else{a.moving=false;a._stuck=0;}
}
/* ---------------- combat ---------------- */
function shoot(st,a,vis){
a.shots=(a.shots||0)+1;
const d=Math.hypot(vis.x-a.x,vis.y-a.y);
const w=a.weapon;
const spread=w.spread*(1+(a.moving?0.85:0))*(1.35-Math.min(d/w.rng,1)*0.6)/(0.45+a.acc*0.8);
const ang=a.ang+(Math.random()-0.5)*spread*5.5;
const maxD=Math.min(w.rng*1.7,d+15);
const hit=raycast(st.g,a.x,a.y,Math.cos(ang),Math.sin(ang),maxD,st.smokes);
let victim=null,hitAt={x:hit.x,y:hit.y};
if(!hit.hit&&!hit.smoke){
let bt=1e9;
for(const e of st.agents){
if(e.dead||e.isCT===a.isCT)continue;
const rx=e.x-a.x,ry=e.y-a.y;
const tt=rx*Math.cos(ang)+ry*Math.sin(ang);
if(tt<0||tt>hit.d)continue;
const px=a.x+Math.cos(ang)*tt,py=a.y+Math.sin(ang)*tt;
const dd=Math.hypot(px-e.x,py-e.y);
if(dd<1.25&&tt<bt){bt=tt;victim=e;hitAt={x:px,y:py};}
}
}
st.fx.push({type:'tracer',x1:a.x+Math.cos(ang)*2,y1:a.y+Math.sin(ang)*2,x2:hitAt.x,y2:hitAt.y,t0:st.t,dur:0.05});
st.fx.push({type:'muzzle',x:a.x+Math.cos(ang)*2.2,y:a.y+Math.sin(ang)*2.2,t0:st.t,dur:0.04,ang});
if(victim){
const fall=U.clamp(1-d/(w.rng*2.4),0.55,1);
let dmg=w.dmg*fall;
const hs=Math.random()<0.15+a.acc*0.12;
if(hs)dmg*=2.4;
if(victim.armor>0){victim.armor=Math.max(0,victim.armor-dmg*0.5);dmg*=0.68;}
dmg=Math.min(dmg,victim.hp);
victim.hp-=dmg;a.dmg+=dmg;
st.fx.push({type:'bloodHit',x:victim.x,y:victim.y,t0:st.t,dur:0.1});
const vz=VZ();if(vz)vz.sHit();
if(victim.hp<=0)kill(st,victim,a,w.name+(hs?' ⌖':''));
}else{
st.fx.push({type:hit.hit?'spark':'puff',x:hitAt.x,y:hitAt.y,t0:st.t,dur:0.13});
}
const vz=VZ();if(vz)vz.sShot(w.big);
}
function kill(st,v,killer,wpn){
if(v.dead)return;
v.dead=true;v.hp=0;
st.corpses.push({x:v.x,y:v.y,ang:v.ang,ct:v.isCT,grow:0});
st.shake=Math.min(st.shake+0.9,2.4);
const vz=VZ();if(vz)vz.sDeath();
const wct=killer?killer.isCT:!v.isCT;
if(killer){
killer.kills++;
st.killsLog.push({k:killer.pid,v:v.pid,wpn:wpn||''});
st.feed.unshift({t0:st.t,killer:killer.nick,kCT:killer.isCT,wpn:wpn||'',victim:v.nick,vCT:v.isCT});
if(st.feed.length>5)st.feed.pop();
}
if(v.side===st.cfg.userSide)st.pain=1;
if(!st.firstBlood){st.firstBlood=true;callout(st,'FIRST BLOOD!','#ff5c5c');}
const aliveCT=st.agents.filter(x=>!x.dead&&x.isCT).length;
const aliveT=st.agents.filter(x=>!x.dead&&!x.isCT).length;
if(aliveCT===1&&aliveT>=2&&!st.clutchFlag&&wct){st.clutchFlag=true;callout(st,'🧊 CLUTCH TIME!','#ffd34d');}
if(aliveT===1&&aliveCT>=2&&!st.clutchFlag&&!wct){st.clutchFlag=true;callout(st,'🧊 CLUTCH TIME!','#ffd34d');}
const tKey=st.cfg.ctSide==='a'?'b':'a';
if(aliveCT===0){
if(st.planted)endRound(st,tKey,'bomb');
else endRound(st,tKey,'elim');
}else if(aliveT===0&&!st.planted){
endRound(st,st.cfg.ctSide,'elim');
}
if(v===st.defuser){st.defuser=null;st.defuseP=0;}
if(v===st.bombCarrier&&!st.planted){
const ts=st.agents.filter(x=>!x.dead&&!x.isCT);
if(ts.length)st.bombCarrier=ts[0];
}
}
function callout(st,txt,color){st.callout={txt,color};st.calloutT=st.t;}
/* ---------------- AI brains ---------------- */
function tBrain(st,a,dt){
// late-round urgency: commit everyone left to the chosen site
if(!st.planted&&!a.fake&&st.timeLeft<78&&!a._rushed){
a._rushed=true;
const tg=st.L.sites[st.site];const gp=snap(st.g,tg[0],tg[1]);
a.goal={x:gp.x,y:gp.y};a.path=null;a.via=null;
}
if(st.planted){
// post-plant: watch entrances near bomb
const entr=st.L.chokes[st.site][0];
a.moving=false;
if(Math.hypot(a.x-st.bombPos.x,a.y-st.bombPos.y)>9)moveAlong(st,a,{x:st.bombPos.x,y:st.bombPos.y},dt);
else a.ang=Math.atan2(entr[1]-a.y,entr[0]-a.x)+(a.bob%1-0.5)*0.5;
return;
}
const siteC=st.L.sites[st.site];
if(a.fake){
const o=st.site==='A'?'B':'A';
const ch=st.L.chokes[o][0];
if(Math.hypot(a.x-ch[0],a.y-ch[1])>6)moveAlong(st,a,{x:ch[0],y:ch[1]},dt);
else if(st.t>16){a.fake=false;const tg=st.L.sites[st.site];a.goal={x:tg[0],y:tg[1]};}
return;
}
const atSite=Math.hypot(a.x-siteC[0],a.y-siteC[1])<9.5;
if(atSite){
let blocker=null,bd=1e9;
st.agents.forEach(e=>{
if(e.dead||!e.isCT)return;
const dd=Math.hypot(e.x-a.x,e.y-a.y);
if(dd<bd&&(dd<8||los(st.g,a,e,st.smokes))){bd=dd;blocker=e;}
});
const desperate=st.timeLeft<30&&bd>4.5;
const enemiesNear=blocker&&!desperate;
if(st.bombCarrier===a&&!enemiesNear){
a.moving=false;
st.plantP+=dt/3.2;
if(Math.floor(st.plantP*8)!==a._lastBeep){a._lastBeep=Math.floor(st.plantP*8);}
if(st.plantP>=1){
st.planted=true;st.bombPos={x:a.x,y:a.y};st.bombT=40;st.plantP=1;
callout(st,'💣 BOMB PLANTED','#ffab4d');
const vz=VZ();if(vz)vz.sPlant();
}
return;
}
if(blocker&&st.timeLeft<70){
// entry fight: collapse onto the defender
moveAlong(st,a,{x:blocker.x,y:blocker.y},dt);
return;
}
// support: crowd around site watching chokes
a.moving=false;
a.ang=Math.atan2(st.L.chokes[st.site][0][1]-a.y,st.L.chokes[st.site][0][0]-a.x)+(a.bob%1-0.5)*0.6;
return;
}
moveAlong(st,a,a.goal,dt);
}
function ctBrain(st,a,dt){
if(st.planted){
if(st.defuser&&st.defuser!==a){}
const d=Math.hypot(a.x-st.bombPos.x,a.y-st.bombPos.y);
if(d>2.3)moveAlong(st,a,st.bombPos,dt);
else{
a.moving=false;
if(visAnyEnemy(st,a))return; // cover first
}
return;
}
const hot=hotSite(st);
const mine=a.holdNear||(a.holdNear=holdSideOf(st,a));
if(hot&&hot!==mine){
moveAlong(st,a,{x:st.L.sites[hot][0],y:st.L.sites[hot][1]},dt);
return;
}
if(a.hold&&st.planted===false&&st.timeLeft<58){
// aggressive CTs collapse toward the hotter choke to break the stalemate
const target=(hot||st.siteGuess||(st.siteGuess=Math.random()<0.5?'A':'B'));
const ch=st.L.chokes[target][0];
if(Math.hypot(a.x-ch[0],a.y-ch[1])>6){moveAlong(st,a,{x:ch[0],y:ch[1]},dt);return;}
}
if(a.hold){
a.moving=false;
const fa=a.hold[2];
const base=fa==='r'?0:fa==='l'?Math.PI:fa==='u'?-Math.PI/2:fa==='d'?Math.PI/2:
fa==='dr'?Math.PI/4:fa==='dl'?Math.PI*0.75:fa==='lu'?-Math.PI*0.75:-Math.PI/4;
a.ang=base+Math.sin(st.t*0.85+a.bob)*0.07;
}
}
/* ---------------- main step ---------------- */
function step(st,dt){
if(st.ended){st.endT+=dt;decayFx(st);return;}
st.timeLeft-=dt;
if(st.timeLeft<=0&&!st.planted){endRound(st,st.cfg.ctSide,'time');decayFx(st);return;}
st.t+=dt;
// scheduled attacker utility
st.utilPlan.forEach(u=>{
const key=''+u.t+'_'+u.type;
if(st.t>=u.t&&!st.utilDone.has(key)){
st.utilDone.add(key);
throwNade(st,u.type,u.x,u.y);
}
});
// nades
st.nades=st.nades.filter(n=>{
n.fuse-=dt;n.progress+=dt/n.total;n.z=Math.sin(Math.min(n.progress,1)*Math.PI)*3;
if(n.fuse<=0){detonate(st,n);return false;}
return true;
});
st.smokes.forEach(s=>s.life+=dt/17);
st.smokes=st.smokes.filter(s=>s.life<1);
st.fires.forEach(f=>{
f.life+=dt/5;
st.agents.forEach(a=>{
if(!a.dead&&Math.hypot(a.x-f.x,a.y-f.y)<f.r){a.hp-=dt*15;
if(a.hp<=0)kill(st,a,null,'🔥 Molly');}
});
});
st.fires=st.fires.filter(f=>f.life<1);
st.agents.forEach(a=>{
if(a.dead)return;
a.cool-=dt;
if(a.blind>0)a.blind-=dt;
if(a.hp<=0){kill(st,a,null,'🔥');return;}
// perception
let vis=null;
if(a.blind<=0){
if(st.t-(a._vt||-9)>=0.12){
let visD=1e9;
for(const e of st.agents){
if(e.dead||e.isCT===a.isCT)continue;
const d=Math.hypot(e.x-a.x,e.y-a.y);
if(d>Math.min(62,a.weapon.rng*1.5+8))continue;
const da=Math.atan2(e.y-a.y,e.x-a.x);
const diff=Math.abs(((da-a.ang)+Math.PI*3)%(Math.PI*2)-Math.PI);
if(d>3.5&&diff>1.2)continue;
if(los(st.g,a,e,st.smokes)&&d<visD){vis=e;visD=d;}
}
a._vis=vis;a._vt=st.t;
}
vis=a._vis&&!a._vis.dead?a._vis:null;
}
if(vis){
a.target=vis;a.seenX=vis.x;a.seenY=vis.y;a.lastSeenT=st.t;
st.agents.forEach(m=>{
if(m!==a&&!m.dead&&m.isCT===a.isCT&&Math.hypot(m.x-a.x,m.y-a.y)<26)m.alertT=st.t;
});
a.reactT=(a.reactT===null||a.reactT===undefined)?a.react*(0.7+Math.random()*0.6):a.reactT-dt;
a.ang=Math.atan2(vis.y-a.y,vis.x-a.x)+(Math.random()-0.5)*0.05;
if(a.reactT<=0&&a.cool<=0){shoot(st,a,vis);a.cool=60/a.weapon.rpm;}
// attackers keep pushing while trading (entry discipline by tactic)
if(!a.isCT&&!st.planted){
const gd=a.goal?Math.hypot(a.goal.x-a.x,a.goal.y-a.y):99;
const push=st.cfg.tactic[st.tKey]==='aggr'?1:0.45;
if(gd>7&&Math.random()<push)moveAlong(st,a,a.goal,dt);
}else a.moving=false;
return;
}
if(a.target&&(st.t-a.lastSeenT>1.3)){a.target=null;a.reactT=null;}
if(a.isCT)ctBrain(st,a,dt);else tBrain(st,a,dt);
});
// defuse
if(st.planted){
st.bombT-=dt;
if(st.bombT<=0){explodeBomb(st);return;}
if(st.defuser){
const du=st.defuser;
if(du.dead||Math.hypot(du.x-st.bombPos.x,du.y-st.bombPos.y)>2.4){st.defuser=null;st.defuseP=0;}
else{
st.defuseP+=dt/9;
if(st.defuseP>=1){endRound(st,st.cfg.ctSide,'defuse');return;}
}
}else{
for(const c of st.agents){
if(c.dead||!c.isCT)continue;
if(Math.hypot(c.x-st.bombPos.x,c.y-st.bombPos.y)<2.5&&!visAnyEnemy(st,c)){
st.defuser=c;c.moving=false;break;
}
}
}
}
// gentle separation
const al=st.agents.filter(a=>!a.dead);
for(let i=0;i<al.length;i++)for(let j=i+1;j<al.length;j++){
const A=al[i],B=al[j];
const dx=B.x-A.x,dy=B.y-A.y,d=Math.hypot(dx,dy);
if(d>0.01&&d<1.4){
const px=dx/d*(1.4-d)*0.5,py=dy/d*(1.4-d)*0.5;
if(walkable(st.g,A.x-px,A.y-py)){A.x-=px;A.y-=py;}
if(walkable(st.g,B.x+px,B.y+py)){B.x+=px;B.y+=py;}
}
}
decayFx(st);
}
function decayFx(st){
st.fx=st.fx.filter(f=>(st.t-f.t0)<f.dur);
st.pain*=0.985;st.shake*=0.9;
}
function explodeBomb(st){
st.fx.push({type:'boom',x:st.bombPos.x,y:st.bombPos.y,t0:st.t,dur:0.6});
st.fx.push({type:'flashPop',t0:st.t,dur:0.22});
const vz=VZ();if(vz)vz.sBoom();
st.agents.forEach(a=>{
if(!a.dead&&Math.hypot(a.x-st.bombPos.x,a.y-st.bombPos.y)<10)a.hp-=200;
});
// mark deaths without recursion issues
st.agents.filter(a=>!a.dead&&a.hp<=0).forEach(a=>kill(st,a,null,'💥 C4'));
endRound(st,st.tKey,'bomb');
}
function endRound(st,winnerKey,reason){
if(st.ended)return;
st.ended=true;st.winner=winnerKey;st.reason=reason;st.endT=0;
const won=winnerKey===st.cfg.userSide;
const vz=VZ();if(vz)vz.sWinSting(won);
}
return {MAPS,buildGrid,astar,raycast,losClear,makeRound,step,WEAPONS,
ensureConnectedForTest:(L,g)=>{if(!L._conn){ensureConnected(L,g);L._conn=true;}}};
})();
if(typeof window!=='undefined')window.LIVE=LIVE;