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)
This commit is contained in:
+603
@@ -0,0 +1,603 @@
|
||||
'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();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user