Files
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

604 lines
24 KiB
JavaScript

'use strict';
/* ================= match engine ================= */
/* Shared round-resolution core used by both quick-sim and the interactive Simulation mode. */
const WEAPONS={
pistol:['Glock','USP-S','P250','Tec-9'],
smg:['MAC-10','MP9','UMP-45'],
rifle:['AK-47','M4A1-S','Galil AR','FAMAS'],
awp:'AWP',
};
const TACTICS=[
{id:'aggr', name:'⚡ Fast Execute', desc:'Hit fast, win raw duels. Beats deception.', beats:'fake'},
{id:'default',name:'🧭 Default', desc:'Balanced structured CS.', beats:null},
{id:'patient',name:'🐢 Patient Play', desc:'Slow, trade-heavy. Punishes aggression.', beats:'aggr'},
{id:'fake', name:'🎭 Fake & Rotate', desc:'Deceive and punish rotations. Beats patience.',beats:'patient'},
{id:'stack', name:'🧱 Site Stack', desc:'All-in one site. High risk, high reward.', beats:null},
];
function tacticEdge(a,b){ // small RPS edges
if(!a||!b)return 0;
const t=TACTICS.find(x=>x.id===a), o=b;
if(t.beats===o)return 0.06;
const ot=TACTICS.find(x=>x.id===o);
if(ot&&ot.beats===a)return -0.06;
return 0;
}
const MatchEngine={
lineupOf(teamId){
let t=Game.team(teamId);
if(!t.lineup||t.lineup.length<5||(t.lineup.some(pid=>Game.p(pid).injuryDays>0)&&t.lineup.filter(pid=>Game.p(pid).injuryDays===0).length<5)){
Game.autoLineup(t);
}
return t.lineup.slice(0,5);
},
playerWeight(p){
return Math.pow(playerOverall(p)/60,3)*(0.8+p.form*0.04)*(p.role==='AWP'?1.12:1);
},
mapStrength(teamId,map){
const t=Game.team(teamId);
return Game.teamStrength(t,{prepped:true})+(t.mapAff[map]||0);
},
preview(m){
const A=Game.team(m.a),B=Game.team(m.b);
const la=this.lineupOf(m.a),lb=this.lineupOf(m.b);
const sA=U.avg(la.map(pid=>playerOverall(Game.p(pid)))),sB=U.avg(lb.map(pid=>playerOverall(Game.p(pid))));
return {
A,B,sA,sB,
strA:this.mapStrength(m.a,'Mirage'),strB:this.mapStrength(m.b,'Mirage'),
pWin:U.logistic((sA-sB)*0.09),
rankA:Game.rankOf(A.id)||'—',rankB:Game.rankOf(B.id)||'—',
};
},
/* quick veto: returns array of maps to play (up to bo maps) */
planMaps(aId,bId,bo){
const nPlay=bo;
const pool=ACTIVE_MAPS.map(map=>{
const d=this.mapStrength(aId,map)-this.mapStrength(bId,map);
return {map,d};
});
const avail=new Set(pool.map(x=>x.map));
const bans=U.clamp(ACTIVE_MAPS.length-nPlay,0,4);
let turn=U.chance(.5)?0:1;
for(let i=0;i<bans;i++){
const side=i%2===0?turn:1-turn;
let best=null;
pool.forEach(x=>{if(!avail.has(x.map))return;
const v=side===0?-x.d:x.d; // ban worst own map
if(!best||v>best.v)best={map:x.map,v};
});
avail.delete(best.map);
}
// order remaining maps by alternating preference (picks then deciders)
const seq=[];
while(avail.size){
const side=((seq.length%2===0?turn:1-turn))%2;
let best=null;
pool.forEach(x=>{if(!avail.has(x.map))return;
const v=side===0?x.d:-x.d;
if(!best||v>best.v)best={map:x.map,v};
});
avail.delete(best.map);seq.push(best.map);
}
return seq.slice(0,nPlay);
},
/* ---------- ROUND CORE ---------- */
newMapState(aId,bId,map,bo,ctx){
const aCt=U.chance(0.5);
return {
aId,bId,map,bo,
sa:0,sb:0,round:0,
aStartCT:aCt,
fund:{a:4200,b:4200},
lossStreak:{a:0,b:0},
momentum:0, // + favors A
timeouts:{a:2,b:2},
winScore:13,
otSets:0,
hist:{a:[],b:[]}, // tactic history this map
log:[],
box:{}, // pid -> {k,d,adr,rating,clutch}
aLineup:this.lineupOf(aId), bLineup:this.lineupOf(bId),
finished:false,
};
},
sideOf(st,teamKey,roundNo){ // is this team CT this round?
const firstHalfCT=teamKey==='a'?st.aStartCT:!st.aStartCT;
const swapped=roundNo>=12&&st.winScore===13; // halftime only in regulation
return swapped?!firstHalfCT:firstHalfCT;
},
autoBuy(st,key){
const f=st.fund[key];
const opp=key==='a'?'b':'a';
if(st.round===0)return 'pistol';
if(f>=11500)return 'full';
if(f>=6500&&st.fund[opp]<11500)return 'force';
if(f>=6500)return U.chance(.45)?'force':'eco';
return 'eco';
},
autoTactic(st,key){
const t=Game.team(key==='a'?st.aId:st.bId);
const coach=Game.staffOf(t,'Coach');
// counter user tendencies if smart staff
const opp=key==='a'?'b':'a';
const h=st.hist[opp];
let choice;
if(h.length>=3&&(coach&&coach.skill>70)&&U.chance(coach.skill/180)){
const counts={};h.forEach(x=>counts[x]=(counts[x]||0)+1);
const likely=Object.entries(counts).sort((x,y)=>y[1]-x[1])[0][0];
const counter={aggr:'patient',patient:'fake',fake:'aggr',default:'aggr',stack:'default'}[likely];
choice=counter||'default';
}else{
choice=U.pick(['aggr','default','patient','default','fake']);
}
return choice;
},
resolveRound(st,opt={}){
const r=st.round;
const keyA='a',keyB='b';
let buyA=opt.buyA??this.autoBuy(st,'a');
let buyB=opt.buyB??this.autoBuy(st,'b');
if(r===0){buyA='pistol';buyB='pistol';}
let tacA=opt.tacA??this.autoTactic(st,'a');
let tacB=opt.tacB??this.autoTactic(st,'b');
st.hist.a.push(tacA);st.hist.b.push(tacB);
const strA=this.mapStrength(st.aId,st.map),strB=this.mapStrength(st.bId,st.map);
let p=strA-strB; // base diff in points (~ -20..20)
// side & map bias
const mapObj=MAPS.find(x=>x.name===st.map);
const biasEdge=(this.sideOf(st,'a',r)?mapObj.bias:-mapObj.bias)*0.22;
p+=biasEdge;
// economy
const buyVal={full:3,force:2,eco:1,pistol:1.4};
p+=(buyVal[buyA]-buyVal[buyB])*11;
if(buyA==='eco'&&buyB==='full')p-=6; // save rounds are brutal
if(buyB==='eco'&&buyA==='full')p+=6;
// tactics
p+=tacticEdge(tacA,tacB)*100*0.9;
// momentum
p+=st.momentum*0.32;
// timeout calm-down
if(opt.usedTimeoutA)p+=2.2;
if(opt.usedTimeoutB)p-=2.2;
// convert to probability
let pw=U.logistic(p*0.058);
pw=U.clamp(pw,0.05,0.95);
const aWins=U.chance(pw);
const winKey=aWins?'a':'b', loseKey=aWins?'b':'a';
const events=[];
const wLineup=winKey==='a'?st.aLineup:st.bLineup;
const lLineup=winKey==='a'?st.bLineup:st.aLineup;
// survivors flavor: how many died on each side
const survWin=U.pickW([1,2,3,4,5],v=>v===5?2:v);
const survLose=U.pickW([0,1,2,3],v=>v===3?1.2:v);
let clutchHappened=false;
if(survWin<=2&&survLose===1){
// potential 1vX clutch for the LAST loser-survivor
const lp=lLineup[U.ri(0,lLineup.length-1)];
const cp=Game.p(lp);
const pClutch=U.clamp(0.05+cp.attrs.clutch*0.0032+(cp.form-5)*0.01,0.02,0.5);
if(U.chance(pClutch)){
clutchHappened=true;
st.box[lp]=st.box[lp]||{k:0,d:0,adr:0,clutch:0};
st.box[lp].clutch++;
events.push({type:'clutch',txt:`🧊 CLUTCH! ${cp.nick} wins the 1v${survWin}!`});
}
}
if(clutchHappened){/* round flips to loser side */}
const finalWin=clutchHappened?loseKey:winKey;
/* --- stats: kills exactly balance deaths ---
winners died: 5-survWin ; losers died: 5-survLose (after possible clutch flip,
survivors counts describe the ORIGINAL winner side, so recompute for final sides) */
const wArr=finalWin==='a'?st.aLineup:st.bLineup;
const lArr=finalWin==='a'?st.bLineup:st.aLineup;
// how many died per side of the FINAL round outcome
const winDead=U.pickW([1,2,3,4],v=>v===4?1.6:v); // 1..4 winners died
const loseDead=U.clamp(5-(clutchHappened?1:survLose),1,5); // at least the clutch survivor lives
const boxOf=pid=>{st.box[pid]=st.box[pid]||{k:0,d:0,adr:0,_dmg:0,clutch:0};return st.box[pid];};
const giveKills=(lineup,nKills)=>{
if(nKills<=0)return;
const ws=lineup.map(pid=>this.playerWeight(Game.p(pid)));
const tot=U.sum(ws)||1;
let left=nKills;
lineup.forEach((pid,i)=>{
const k=Math.min(left,Math.max(0,Math.round(nKills*ws[i]/tot*(U.rnd(.55,1.45)))));
left-=k;const b=boxOf(pid);b.k+=k;b._dmg+=k*U.rnd(90,160);
});
while(left>0){const pid=U.pick(lineup);const b=boxOf(pid);b.k++;b._dmg+=U.rnd(90,160);left--;}
};
giveKills(wArr,loseDead); // winners killed the losers who died
giveKills(lArr,winDead); // losers' kills = winners who died
U.shuffle(wArr.slice()).slice(0,winDead).forEach(pid=>boxOf(pid).d++);
U.shuffle(lArr.slice()).slice(0,loseDead).forEach(pid=>boxOf(pid).d++);
// event text
if(!opt.silent){
const wpnPool=WEAPONS.pistol.concat(WEAPONS.smg);
if(U.chance(0.75)){
const killer=Game.p(U.pick(wArr)),victim=Game.p(U.pick(lArr));
const fullBuy=(finalWin==='a'?buyA:buyB)==='full';
const wpn=killer.role==='AWP'&&fullBuy?WEAPONS.awp:(fullBuy?U.pick(WEAPONS.rifle):U.pick(wpnPool));
events.push({type:'kill',txt:`${killer.nick} [${wpn}] ${victim.nick}`});
}
if(U.chance(0.05)){
const ace=Game.p(U.pick(wArr));
events.push({type:'info',txt:`💥 ${ace.nick} with a MULTI-KILL!`});
}
}
// economy update
const rewardWin=3250+(U.chance(0.4)?800:0);
const ls=st.lossStreak;
ls[loseKey]=Math.min(ls[loseKey]+1,4);ls[winKey]=0;
const rewardLose=1400+500*(ls[loseKey]-1);
const spendA={full:11800,force:6800,eco:1200,pistol:800}[buyA];
const spendB={full:11800,force:6800,eco:1200,pistol:800}[buyB];
st.fund.a=U.clamp(st.fund.a-spendA+(finalWin==='a'?rewardWin:rewardLose),0,16000*5);
st.fund.b=U.clamp(st.fund.b-spendB+(finalWin==='b'?rewardWin:rewardLose),0,16000*5);
// cap funds realistically
st.fund.a=Math.min(st.fund.a,52000);st.fund.b=Math.min(st.fund.b,52000);
// score & momentum
if(finalWin==='a')st.sa++;else st.sb++;
st.momentum=U.clamp(st.momentum+(finalWin==='a'?1.4:-1.4)* (clutchHappened?1.8:1),-8,8);
if(opt.usedTimeoutA)st.momentum*=0.7;
if(opt.usedTimeoutB)st.momentum*=0.7;
st.round++;
// win condition incl. OT checkpoints (MR12 → 13; OT to 16, 19, 22…)
if(st.winScore===13){
if(st.sa>=13||st.sb>=13)st.finished=true;
else if(st.sa===12&&st.sb===12){st.winScore=16;if(!opt.silent)st.log.push('⏱ OVERTIME — first to 16!');}
}else{
const hi=Math.max(st.sa,st.sb);
if(hi>=st.winScore)st.finished=true;
else if(st.sa===st.sb&&st.sa===st.winScore-1){st.winScore+=3;if(!opt.silent)st.log.push(`⏱ ${st.sa}-${st.sb} — another OT set! First to ${st.winScore}`);}
}
return {finalWin,aWins,buyA,buyB,tacA,tacB,events,pw,clutchHappened,
score:[st.sa,st.sb]};
},
/* ---------- full map sim ---------- */
simMap(st,opt={}){
let guard=0;
while(!st.finished&&guard++<300){
this.resolveRound(st,{silent:!opt.verbose});
}
// finalize box ratings
Object.entries(st.box).forEach(([pid,b])=>{
b.adr=U.clamp(Math.round((b._dmg||b.k*90)/Math.max(st.round,1)),10,180);
b.rating=U.clamp(0.92+(b.k-b.d)/40+(b.adr-72)/280+b.clutch*0.05,0.25,1.9);
delete b._dmg;
});
return st;
},
/* ---------- whole fixture ---------- */
simulate(m,ev){
const A=Game.team(m.a),B=Game.team(m.b);
const la=this.lineupOf(m.a),lb=this.lineupOf(m.b);
if(A.lineup.join()!==la.join())A.lineup=la;
if(B.lineup.join()!==lb.join())B.lineup=lb;
const bo=m.bo||3, need=Math.ceil(bo/2);
const mapSeq=this.planMaps(m.a,m.b,bo);
let sa=0,sb=0;
const maps=[],box={};
for(const map of mapSeq){
if(sa>=need||sb>=need)break;
const st=this.newMapState(m.a,m.b,map,bo,{});
this.simMap(st,{});
const aw=st.sa>st.sb;
if(aw)sa++;else sb++;
maps.push({map,score:`${aw?st.sa:st.sb}-${aw?st.sb:st.sa}`,winner:aw?m.a:m.b});
Object.entries(st.box).forEach(([pid,b])=>{
box[pid]=box[pid]||{k:0,d:0,adr:0,ratingSum:0,clutch:0,mapsN:0,mvp:false,aces:0};
box[pid].k+=b.k;box[pid].d+=b.d;box[pid].adr+=b.adr;box[pid].ratingSum+=b.rating;
box[pid].clutch+=b.clutch;box[pid].mapsN++;
box[pid].aces+=b.clutch>=2?1:0;
});
}
// MVP = best rated on winning side
const winId=sa>sb?m.a:m.b;
let mvpPid=null,best=-1;
(winId===m.a?la:lb).forEach(pid=>{
const b=box[pid];const r=b?b.ratingSum/Math.max(b.mapsN,1):0.8;
if(r>best){best=r;mvpPid=pid;}
});
if(box[mvpPid])box[mvpPid].mvp=true;
const res={
winner:winId,score:bo===1?(sa?`${maps[0].score}`:`${maps[0].score}`):`${sa}-${sb}`,
maps,box,mvp:mvpPid,
};
if(ev)m.evId=ev.id;
Game.finishMatch(m,res);
return res;
},
};
'use strict';
/* ================= INTERACTIVE SESSION (Simulation Mode) ================= */
const SimSession={
s:null,
start(m,ev){
const userId=m.a===G.playerTeamId?'a':'b';
this.s={
m,ev,phase:'veto',
userId, aId:m.a,bId:m.b,bo:m.bo||3,
veto:{pool:new Set(ACTIVE_MAPS),seq:[],step:0,
order:U.chance(.5)?['u','ai']:['ai','u'],log:['🗳 Map VETO begins…']},
mapStates:[],results:[],cur:null,curIdx:-1,
awaiting:null,lastRound:null,
scoreA:0,scoreB:0,userBuy:null,userTac:null,pendingTimeout:false,roundResolved:false,
};
// if AI moves first in veto, resolve its moves
while(!this.vetoDone()&&!this.myTurn())this.aiVetoAction();
return this.s;
},
oppId(){return this.s.userId==='a'?this.s.bId:this.s.aId;},
myTurn(){return this.s.veto.order[this.s.veto.step%2]==='u';},
winsNeeded(){return Math.ceil(this.s.bo/2);}, // series wins needed
totalVetoSteps(){return this.s.bo===1?4:U.clamp(ACTIVE_MAPS.length-this.s.bo,0,4);},
picksCount(){return this.s.bo===1?0:2;},
vetoDone(){return this.s.veto.step>=this.totalVetoSteps()+this.picksCount();},
vetoAction(map){
const v=this.s.veto,S=this.s;
if(this.vetoDone()||!v.pool.has(map))return;
const isBan=v.step<this.totalVetoSteps();
if(isBan)v.pool.delete(map);else{v.seq.push(map);v.pool.delete(map);}
v.log.push(`${isBan?'🚫 Ban':'✅ Pick'} — YOU: ${map}`);
v.step++;
while(!this.vetoDone()&&!this.myTurn())this.aiVetoAction();
if(this.vetoDone()){
const rest=[...v.pool];U.shuffle(rest);
while(v.seq.length<Math.min(S.bo,ACTIVE_MAPS.length)&&rest.length){v.seq.push(rest.shift());v.pool.delete(v.seq.at(-1));}
S.phase='pregame';
}
},
aiVetoAction(){
const S=this.s,v=S.veto;
if(this.vetoDone())return;
const aiId=this.oppId();
const isBan=v.step<this.totalVetoSteps();
let best=null;
[...v.pool].forEach(mp=>{
const mine=MatchEngine.mapStrength(aiId,mp);
const opp=MatchEngine.mapStrength(S.userId==='a'?S.aId:S.bId,mp);
const val=isBan?(opp-mine):(mine-opp);
if(!best||val>best.val)best={map:mp,val};
});
if(!best)return;
if(isBan)v.pool.delete(best.map);else{v.seq.push(best.map);v.pool.delete(best.map);}
v.log.push(`${isBan?'🚫 Ban':'✅ Pick'}${Game.team(aiId).tag}: ${best.map}`);
v.step++;
},
beginMap(idx){
const S=this.s;
const map=S.veto.seq[idx];
S.curIdx=idx;
S.cur=MatchEngine.newMapState(S.aId,S.bId,map,S.bo,{});
S.phase='round';
S.awaiting='buy';
S.userBuy=null;S.userTac=null;S.pendingTimeout=false;S.roundResolved=false;
return S.cur;
},
buyOptions(){
const c=this.s.cur,k=this.s.userId,f=c.fund[k];
const opts=[];
if(c.round===0)return [{id:'pistol',name:'🔫 Pistol Round',desc:'Default pistols + utility',cost:800}];
if(f>=11800)opts.push({id:'full',name:'🛒 Full Buy',desc:'Rifles, armor, full utility',cost:11800});
if(f>=6800)opts.push({id:'force',name:'🔨 Force Buy',desc:'Mix of guns, partial armor',cost:6800});
opts.push({id:'eco',name:'💰 Eco / Save',desc:'Bank cash for a later full round',cost:1200});
return opts;
},
chooseBuy(opt){
const S=this.s;if(S.phase!=='round'||S.awaiting!=='buy')return;
S.userBuy=opt;S.awaiting='tactic';
if(typeof UI!=='undefined'&&UI.renderSim)UI.renderSim();
},
chooseTactic(tid){
const S=this.s;if(S.awaiting!=='tactic')return;
S.userTac=tid;this.playRound(); // playRound owns rendering (may start the viz)
},
callTimeout(){
const S=this.s,c=S.cur,k=S.userId;
if(c.timeouts[k]<=0||S.roundResolved)return false;
c.timeouts[k]--;S.pendingTimeout=true;
c.log.push(`🎙 <b>${Game.team(k==='a'?S.aId:S.bId).tag}</b> burns a tactical timeout.`);
return true;
},
playRound(){
const S=this.s,c=S.cur;
if(S.awaiting!=='tactic')return;
const opt={};
if(S.userId==='a'){opt.buyA=S.userBuy;opt.tacA=S.userTac;opt.usedTimeoutA=S.pendingTimeout;}
else{opt.buyB=S.userBuy;opt.tacB=S.userTac;opt.usedTimeoutB=S.pendingTimeout;}
S.pendingTimeout=false;
/* LIVE tactical simulation: the round is decided by an actual
top-down firefight, then its result is fed back into the
career engine (score/economy/box stats). */
if(typeof LIVE!=='undefined'&&typeof VIZ!=='undefined'&&VIZ.ok){
try{
const cfg=this.buildLiveCfg(S,c,opt);
S.awaiting='anim';
UI.renderSim();
VIZ.play(cfg,(st)=>{
if(st)this.applyLiveResult(S,c,st,cfg,opt);
this._finishLive();
});
return;
}catch(e){G.ui.vizErr=(e&&e.message)+' @ '+((e&&e.stack||'').split('\n')[1]||'?');}
}
/* fallback: math model */
const out=MatchEngine.resolveRound(c,opt);
S.lastRound=out;
out.events.forEach(e=>c.log.push(e.txt));
const wTag=Game.team(out.finalWin==='a'?S.aId:S.bId).tag;
const tagA=Game.team(S.aId).tag,tagB=Game.team(S.bId).tag;
c.log.push(`<b>R${c.round}</b> ${tagA} ${out.buyA.toUpperCase()} vs ${out.buyB.toUpperCase()} → <b>${wTag}</b> (${c.sa}-${c.sb})`);
S.roundResolved=true;
S.awaiting='next';
if(c.finished)this.endMap();
if(typeof UI!=='undefined'&&UI.renderSim)UI.renderSim();
},
buildLiveCfg(S,c,opt){
const r=c.round;
let buyA=opt.buyA??MatchEngine.autoBuy(c,'a');
let buyB=opt.buyB??MatchEngine.autoBuy(c,'b');
if(r===0){buyA='pistol';buyB='pistol';}
const tacA=opt.tacA??MatchEngine.autoTactic(c,'a');
const tacB=opt.tacB??MatchEngine.autoTactic(c,'b');
c.hist.a.push(tacA);c.hist.b.push(tacB);
const ctSide=MatchEngine.sideOf(c,'a',r)?'a':'b';
const players={},skills={};
c.aLineup.concat(c.bLineup).forEach(pid=>{
const p=Game.p(pid);
players[pid]={nick:p.nick,role:p.role};
skills[pid]={
aim:p.attrs.aim+(p.form-5)*2,
spd:p.attrs.move,
clutch:p.attrs.clutch,
};
});
const tA=Game.team(S.aId),tB=Game.team(S.bId);
return {map:c.map,ctSide,lineups:{a:c.aLineup.slice(),b:c.bLineup.slice()},
players,skills,buys:{a:buyA,b:buyB},tactic:{a:tacA,b:tacB},
tags:{a:tA.tag,b:tB.tag},colors:{a:tA.color,b:tB.color},
userSide:S.userId,scoreAfter:[c.sa,c.sb],
timeoutSide:opt.usedTimeoutA?'a':opt.usedTimeoutB?'b':null};
},
applyLiveResult(S,c,st,cfg,opt){
const finalWin=st.winner;
/* real fight stats → box score */
st.agents.forEach(a=>{
c.box[a.pid]=c.box[a.pid]||{k:0,d:0,adr:0,_dmg:0,clutch:0};
const b=c.box[a.pid];
b.k+=a.kills;b._dmg+=Math.round(a.dmg);
if(a.dead)b.d++;
});
let clutchPid=null;
if(st.clutchFlag){
let best=null;
st.agents.forEach(x=>{if(x.side===finalWin&&!x.dead&&(!best||x.kills>best.kills))best=x;});
if(best){c.box[best.pid].clutch++;clutchPid=best.pid;
c.log.push(`🧊 CLUTCH! ${Game.p(best.pid).nick} seals the round!`);}
}
st.feed.slice().reverse().forEach(f=>c.log.push(`🔫 ${f.killer} [${f.wpn}] ${f.victim}`));
/* economy — same model as resolveRound */
const rewardWin=3250+(U.chance(0.4)?800:0);
const winKey=finalWin,loseKey=finalWin==='a'?'b':'a';
const ls=c.lossStreak;
ls[loseKey]=Math.min(ls[loseKey]+1,4);ls[winKey]=0;
const rewardLose=1400+500*(ls[loseKey]-1);
const spend={full:11800,force:6800,eco:1200,pistol:800};
c.fund.a=U.clamp(c.fund.a-spend[cfg.buys.a]+(finalWin==='a'?rewardWin:rewardLose),0,52000);
c.fund.b=U.clamp(c.fund.b-spend[cfg.buys.b]+(finalWin==='b'?rewardWin:rewardLose),0,52000);
/* score & momentum & win condition */
if(finalWin==='a')c.sa++;else c.sb++;
c.momentum=U.clamp(c.momentum+(finalWin==='a'?1.4:-1.4)*(st.clutchFlag?1.8:1),-8,8);
if(opt.usedTimeoutA)c.momentum*=0.7;
if(opt.usedTimeoutB)c.momentum*=0.7;
c.round++;
if(c.winScore===13){
if(c.sa>=13||c.sb>=13)c.finished=true;
else if(c.sa===12&&c.sb===12){c.winScore=16;c.log.push('⏱ OVERTIME — first to 16!');}
}else{
const hi=Math.max(c.sa,c.sb);
if(hi>=c.winScore)c.finished=true;
else if(c.sa===c.sb&&c.sa===c.winScore-1){c.winScore+=3;c.log.push(`⏱ ${c.sa}-${c.sb} — another OT set! First to ${c.winScore}`);}
}
const wTag=Game.team(finalWin==='a'?S.aId:S.bId).tag;
const tagA=Game.team(S.aId).tag,tagB=Game.team(S.bId).tag;
const reasonTxt={elim:'',bomb:' 💣',defuse:' ✂️',time:' ⏱'}[st.reason]||'';
c.log.push(`<b>R${c.round}</b> ${tagA} ${cfg.buys.a.toUpperCase()} vs ${cfg.buys.b.toUpperCase()} → <b>${wTag}</b>${reasonTxt} (${c.sa}-${c.sb})`);
S.lastRound={finalWin,aWins:finalWin==='a',buyA:cfg.buys.a,buyB:cfg.buys.b,
tacA:cfg.tactic.a,tacB:cfg.tactic.b,events:[],pw:0.5,
clutchHappened:st.clutchFlag,score:[c.sa,c.sb]};
S.roundResolved=true;
},
_finishLive(){
const S=this.s,c=S.cur;
S.awaiting='next';
if(c.finished)this.endMap();
UI.renderSim();
},
nextRound(){
const S=this.s;
if(S.awaiting!=='next')return;
const c=S.cur;
S.userBuy=null;S.userTac=null;S.roundResolved=false;
S.awaiting='buy';
if(typeof UI!=='undefined'&&UI.renderSim)UI.renderSim();
},
simRest(){
const S=this.s,c=S.cur;
if(S.awaiting!=='next')return;
let guard=0;
while(!c.finished&&guard++<400)MatchEngine.resolveRound(c,{silent:true});
// fold quick-simmed rounds into box stats
Object.entries(c.box).forEach(([pid,b])=>{
b.adr=U.clamp(Math.round((b._dmg||b.k*90)/Math.max(c.round,1)),10,180);
b.rating=U.clamp(0.92+(b.k-b.d)/40+(b.adr-72)/280+b.clutch*0.05,0.25,1.9);
delete b._dmg;
});
this.endMap();
},
endMap(){
const S=this.s,c=S.cur;if(!c)return;
const aWon=c.sa>c.sb;
const winKey=aWon?'a':'b';
// finalize this map's ratings
Object.entries(c.box).forEach(([pid,b])=>{
b.adr=U.clamp(Math.round((b._dmg!==undefined?b._dmg:b.k*90)/Math.max(c.round,1)),10,180);
b.rating=U.clamp(0.92+(b.k-b.d)/40+(b.adr-72)/280+b.clutch*0.05,0.25,1.9);
delete b._dmg;
});
S.results.push({map:c.map,sa:c.sa,sb:c.sb,winner:winKey});
if(winKey==='a')S.scoreA++;else S.scoreB++;
// accumulate series box
S.boxAcc=S.boxAcc||{};
Object.entries(c.box).forEach(([pid,b])=>{
S.boxAcc[pid]=S.boxAcc[pid]||{k:0,d:0,adr:0,ratingSum:0,clutch:0,mapsN:0,mvp:false,aces:0};
const t=S.boxAcc[pid];
t.k+=b.k;t.d+=b.d;t.adr+=b.adr;t.ratingSum+=b.rating;
t.clutch+=b.clutch;t.mapsN++;t.aces+=b.clutch>=2?1:0;
});
const need=Math.ceil(S.bo/2);
const logLine=`${Game.team(winKey==='a'?S.aId:S.bId).tag} take ${c.map} ${c.sa}-${c.sb} (series ${S.scoreA}-${S.scoreB})`;
S.veto.log.push(logLine);
if(S.scoreA>=need||S.scoreB>=need){
// series over → finalize real fixture
const winId=winKey==='a'?S.aId:S.bId;
let mvpPid=null,best=-1;
const winLineup=winKey==='a'?c.aLineup:c.bLineup;
winLineup.forEach(pid=>{
const b=S.boxAcc[pid];const r=b?b.ratingSum/Math.max(b.mapsN,1):0.8;
if(r>best){best=r;mvpPid=pid;}
});
if(S.boxAcc[mvpPid])S.boxAcc[mvpPid].mvp=true;
const maps=S.results.map(r=>({map:r.map,
score:`${winKey===r.winner?Math.max(r.sa,r.sb):Math.min(r.sa,r.sb)}-${winKey===r.winner?Math.min(r.sa,r.sb):Math.max(r.sa,r.sb)}`,
winner:winKey==='a'?(r.winner==='a'?S.aId:S.bId):(r.winner==='a'?S.aId:S.bId)}));
const res={winner:winId,score:`${S.scoreA}-${S.scoreB}`,maps,
box:S.boxAcc,mvp:mvpPid};
if(S.ev)S.m.evId=S.ev.id;
Game.finishMatch(S.m,res);
S.m.mvp=mvpPid;
S.phase='done';S.cur=null;
}else{
S.phase='intermission';S.cur=null;
}
if(typeof UI!=='undefined'&&UI.renderSim)UI.renderSim();
},
};