Files
counterstrikemanager/js/game.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

1031 lines
46 KiB
JavaScript

'use strict';
/* ================= core game engine ================= */
const SAVE_KEY='csm26_save_v1';
let G=null;
const DIFFS={
easy: {label:'Rookie',wageMul:0.85,aiAggro:0.6,startMul:1.6,boardStrict:0.7},
normal:{label:'Pro', wageMul:1.0, aiAggro:1.0,startMul:1.0,boardStrict:1.0},
hard: {label:'Legend',wageMul:1.15,aiAggro:1.5,startMul:0.65,boardStrict:1.4},
};
const TRAINING_FOCUS=['Aim','Utility','Tactics','Teamwork','Movement','Rest','Media'];
const FOCUS_ATTR={Aim:'aim',Utility:'util',Tactics:'iq',Teamwork:'comm',Movement:'move'};
const PRIZE_FR=[1,.5,.3,.3,.15,.15,.15,.15,.06];
const PTS_FR=[1,.6,.38,.38,.24,.24,.24,.24,.14];
const Game={
diff:null,
/* ---------- world creation ---------- */
newWorld(diffKey){
G={ver:1,day:0,diffKey,diff:DIFFS[diffKey],
money:0,fans:5000,rep:35,board:62,
players:{},teams:{},staff:{},freeAgents:[],
calendar:[],news:[],nextNewsId:1,
sponsors:[],sponsorOffers:[],
ranking:[],rankingPrev:[],
achievements:{},grandSlamWins:[],
season:1, history:[],
pendingOffers:[], // incoming offers for user players
milestones:[],scoutPool:[],
flags:{}, ui:{},
};
// backfill for older saves
if(!G.milestones)G.milestones=[];
if(!G.scoutPool)G.scoutPool=[];
// teams
TEAMS_DATA.forEach((t,i)=>{
const id='t'+i;
G.teams[id]={id,name:t.name,tag:t.tag,color:t.color,tier:t.tier,region:t.region,
roster:[],lineup:[],staff:{},ratingPts:[1200,900,700][t.tier-1]+U.ri(-80,80),
fam:U.ri(50,95),mapAff:{},isPlayer:false};
t.roster.forEach(pt=>{
const p=makePlayerFromTuple(pt,id,G);
p.salary=this._aiSalary(p);
G.players[p.id]=p; G.teams[id].roster.push(p.id);
});
this.genMapAff(G.teams[id]);
});
// free agents
FREE_AGENTS.forEach(t=>{
const p=makePlayerFromTuple(t,null,G);
p.salary=0; p.contractUntil=0;
G.players[p.id]=p; G.freeAgents.push(p.id);
});
// staff pool (unemployed)
STAFF_POOL.forEach(t=>{
const sid='s'+U.uid();
G.staff[sid]={id:sid,nick:t[0],name:t[1],role:t[2],skill:t[3],salary:t[4],teamId:null};
});
// assign some staff to AI teams (flavor)
const poolIds=Object.keys(G.staff);
Object.values(G.teams).forEach(t=>{
if(U.chance(.8)){const s=G.staff[U.pick(poolIds)]; if(!s.teamId){s.teamId=t.id; t.staff.Coach=s.id;} }
if(U.chance(.6)){const s=G.staff[U.pick(poolIds)]; if(!s.teamId){s.teamId=t.id; t.staff.Analyst=s.id;} }
});
// calendar
CAL_2026.forEach((e,i)=>{
G.calendar.push({id:'e'+i,...e,startDay:e.day,status:'open',teams:[],champion:null,
rounds:[],registered:false,userOut:false});
});
// initial lineup = best five by overall
Object.values(G.teams).forEach(t=>this.autoLineup(t));
},
genMapAff(team){
ACTIVE_MAPS.forEach(m=>{ team.mapAff[m]=+(U.gauss(0,3)).toFixed(1); });
},
_aiSalary(p){
return Math.round(wageDemand(p,G)*0.95/250)*250;
},
startWithTeam(teamId){
const t=G.teams[teamId];
t.isPlayer=true;
G.playerTeamId=teamId;
G.money=Math.round([700000,1050000,1700000][t.tier-1]*G.diff.startMul);
this.pushNews('welcome',`Welcome to ${t.name}!`,
`You are the new General Manager of ${t.name}. The board expects progress. Build the roster, plan training, win trophies — your way.`,'org');
this.pushNews('objective','Season objectives',
`Finish inside the TOP ${(t.tier===1?'8':t.tier===2?'14':'20')} of the world ranking this season and keep the club financially healthy.`,'board');
this.computeRanking();
},
startCustom(){
const name=document.getElementById('cName').value.trim()||'Northern Lights';
const tag=(document.getElementById('cTag').value.trim()||'NLHT').toUpperCase();
const region=document.getElementById('cRegion').value;
const budgetSel=document.getElementById('cBudget').value;
G.money=budgetSel==='small'?250000:budgetSel==='mid'?600000:1500000;
const id='tcust';
const color='#'+['ff7a1a','38bdf8','34d399','a78bfa','f43f5e'][U.ri(0,4)];
G.teams[id]={id,name,tag,color,tier:4,region,roster:[],lineup:[],staff:{},
ratingPts:420,fam:20,mapAff:{},isPlayer:true};
G.playerTeamId=id;
// draft 5 prospects
const roles=['IGL','AWP','Entry','Support','Rifler'];
roles.forEach(r=>{
const nick=U.pick(NICK_SYL1)+U.pick(NICK_SYL2);
const p={
id:U.uid(),nick,name:U.pick(FIRST_NAMES)+' '+U.pick(LAST_NAMES),age:U.ri(17,21),
nat:U.pick(Object.keys(NATIONS)),role:r,
attrs:Object.fromEntries(['aim','pos','clutch','util','iq','move','comm'].map(k=>[k,U.ri(48,64)])),
potential:U.ri(82,96),
form:5,morale:75,motivation:75,fatigue:8,injuryDays:0,teamId:id,
contractUntil:G.day+700,salary:U.ri(800,1800),listedPrice:null,loanTo:null,
personality:{prof:U.ri(40,85),temper:U.ri(30,80),ambition:U.ri(60,95)},
stats:this.zeroStats(),seasonStats:this.zeroStats(),
training:{focus:'Balanced',intensity:1},talkCooldown:0,promise:null};
G.players[p.id]=p; G.teams[id].roster.push(p.id);
});
this.genMapAff(G.teams[id]); this.autoLineup(G.teams[id]);
this.pushNews('welcome',`A new era begins — ${name}`,
`You founded ${name} (${tag}) in the ${region} region. Five hungry prospects signed their first contracts. Prove the doubters wrong.`,'org');
this.pushNews('objective','Season objectives',
'Turn a squad of semi-pros into a ranked organization. Climb into the world TOP 30 and stay solvent.','board');
this.computeRanking();
if(typeof UI!=='undefined'&&UI.enterGame)UI.enterGame();
},
zeroStats(){return {maps:0,kills:0,deaths:0,adr:0,ratingSum:0,mvp:0,aces:0};},
/* ---------- helpers ---------- */
me(){return G.teams[G.playerTeamId];},
team(id){return G.teams[id];},
p(pid){return G.players[pid];},
teamPlayers(t){return t.roster.map(id=>G.players[id]);},
lineupPlayers(t){return t.lineup.map(id=>G.players[id]).filter(Boolean);},
autoLineup(t){
const avail=t.roster.filter(pid=>!G.players[pid].injuryDays && !G.players[pid].loanFrom);
avail.sort((a,b)=>playerOverall(G.players[b])-playerOverall(G.players[a]));
t.lineup=avail.slice(0,Math.min(5,avail.length));
if(t.lineup.length<5){ // fill with injured if desperate
t.roster.filter(pid=>!t.lineup.includes(pid)&&!G.players[pid].loanFrom).slice(0,5-t.lineup.length)
.forEach(pid=>t.lineup.push(pid));
}
},
staffOf(t,role){return t.staff[role]?G.staff[t.staff[role]]:null;},
teamStrength(t,opt={}){
const ps=this.lineupPlayers(t);
if(!ps.length)return 40;
let base=U.avg(ps.map(playerOverall));
const formAdj=U.avg(ps.map(p=>(p.form-5.5)*0.8));
const morAdj=U.avg(ps.map(p=>(p.morale-60)*0.018));
const fatAdj=-Math.max(0,U.avg(ps.map(p=>p.fatigue))-55)*0.05;
const chem=(t.fam-60)*0.02;
let coachBonus=0,analystBonus=0;
const c=this.staffOf(t,'Coach'); if(c)coachBonus=(c.skill-55)*0.035;
const a=this.staffOf(t,'Analyst'); if(a)analystBonus=(a.skill-55)*0.02;
return U.clamp(base+formAdj+morAdj+fatAdj+chem+coachBonus+analystBonus,30,99);
},
/* season objectives for the user's club */
objectives(){
const t=this.me();
const targetRank=t.tier===1?8:t.tier===2?14:30;
const rank=this.rankOf(t.id)||99;
return {
rank,targetRank,
rankOk:rank<=targetRank,
moneyOk:G.money>-100000,
board:Math.round(G.board),
};
},
/* ---------- rankings ---------- */
computeRanking(){
G.rankingPrev=G.ranking.slice();
G.ranking=Object.values(G.teams).sort((a,b)=>b.ratingPts-a.ratingPts).map(t=>t.id);
},
rankOf(teamId){return G.ranking.indexOf(teamId)+1;},
/* ---------- news ---------- */
pushNews(type,title,body,tag,opts={}){
const n={id:G.nextNewsId++,day:G.day,type,title,body,tag:tag||'',read:false,action:opts.action||null};
G.news.unshift(n);
if(G.news.length>140)G.news.length=140;
return n;
},
/* ---------- time ---------- */
advanceWeek(){
this._advDays=7;
return this._advanceLoop();
},
advanceDay(){this._advDays=1;return this._advanceLoop();},
_advanceLoop(){
this._stopFx=null;
while(this._advDays>0){
const fx=this.advanceOneDay();
this._advDays--;
if(fx){this._stopFx=fx;break;}
}
Save.save(true);
return this._stopFx;
},
remainingAdvance(){return this._advDays|0;},
resumeAdvance(){
if(this._advDays<=0){this._advDays=0;return null;}
return this._advanceLoop();
},
advanceOneDay(){
G.day++;
const dow=U.dow(G.day);
/* daily */
Object.values(G.players).forEach(p=>{
if(p.injuryDays>0){p.injuryDays--; if(p.injuryDays===0)this.pushNews('medical',`${p.nick} is back in training`,`${p.nick} has recovered and is available for selection again.`,'medical');}
if(p.talkCooldown>0)p.talkCooldown--;
p.fatigue=U.clamp(p.fatigue-(p.training.focus==='Rest'?4.5:2)+ (p.training.intensity===2&&!p.injuryDays?1.5:0),0,100);
if(p.loanDaysLeft!==undefined&&p.loanDaysLeft>0){
p.loanDaysLeft--;
if(p.loanDaysLeft===0)this.endLoan(p);
}
});
/* squads must always field five — academy promotions cover gaps */
Object.values(G.teams).forEach(t=>this.ensureSquad(t));
/* career milestones */
this.checkMilestones();
/* incoming transfer bids expire */
for(let i=G.pendingOffers.length-1;i>=0;i--){
const b=G.pendingOffers[i];
if(G.day>b.expires){
G.pendingOffers.splice(i,1);
this.pushNews('offer','Bid expired',`${G.teams[b.from]?.name||'A club'} withdrew their ${U.fmtM(b.fee)} offer for ${this.p(b.pid)?.nick||'a player'}.`,'offer');
}
}
/* events */
G.calendar.forEach(ev=>this.tickEvent(ev));
/* weekly */
if(dow===0){this.weeklyTick();}
if(G.day%28===0){this.monthlyTick();}
/* find user fixture due today */
const fx=this.findUserFixtureToday();
if(fx)return fx;
return null;
},
genYouth(teamId,opts={}){
const nick=U.pick(NICK_SYL1)+U.pick(NICK_SYL2);
const roles=['Rifler','Entry','Support','AWP','IGL','Lurker'];
const p={
id:U.uid(),nick,name:U.pick(FIRST_NAMES)+' '+U.pick(LAST_NAMES),
age:opts.age||U.ri(16,19),
nat:U.pick(Object.keys(NATIONS)),role:U.pick(roles),
attrs:Object.fromEntries(['aim','pos','clutch','util','iq','move','comm'].map(k=>[k,U.ri(44,62)])),
potential:opts.pot||U.ri(72,93),
form:4.5,morale:70,motivation:75,fatigue:6,injuryDays:0,teamId,
contractUntil:G.day+730,salary:Math.round(U.rnd(600,1500)/100)*100,
listedPrice:null,scouted:!!opts.scouted,
personality:{prof:U.ri(40,90),temper:U.ri(25,85),ambition:U.ri(55,95)},
stats:this.zeroStats(),seasonStats:this.zeroStats(),
training:{focus:'Balanced',intensity:1},talkCooldown:0,promise:null};
G.players[p.id]=p;
return p;
},
/* ---------- youth scouting ---------- */
SCOUT_COST:80000,
scoutYouth(){
if(G.money<this.SCOUT_COST)return{ok:false,msg:`Scouting camp costs ${U.fmtM(this.SCOUT_COST)}.`};
if((G.scoutPool||[]).length)return{ok:false,msg:'Review your current prospects first.'};
G.money-=this.SCOUT_COST;
const list=[];
for(let i=0;i<3;i++){
const pot=U.clamp(Math.round(U.rnd(74,84)+G.rep/22+U.rnd(-3,6)),70,96);
list.push(this.genYouth(null,{age:U.ri(16,18),pot,scouted:true}));
}
G.scoutPool=list.map(p=>p.id);
return{ok:true,list};
},
signProspect(pid){
const p=G.players[pid];
if(!p||!(G.scoutPool||[]).includes(pid))return{ok:false,msg:'Prospect not available.'};
const me=this.me();
if(me.roster.length>=8)return{ok:false,msg:'Roster is full (8 players max).'};
(G.scoutPool||[]).forEach(x=>{if(x!==pid)delete G.players[x];});
G.scoutPool=[];
p.teamId=me.id;p.contractUntil=G.day+1095;
p.salary=Math.round(wageDemand(p,G)*0.45/100)*100;
me.roster.push(p.id);
this.pushNews('transfer',`🎓 Academy signing: ${p.nick}`,
`${p.nick} (${p.age}, ${p.role}, POT ${p.potential}) signs a three-year academy deal at ${U.fmtM(p.salary)}/mo.`,'transfer');
return{ok:true};
},
/* ---------- career milestones ---------- */
MILESTONES:[
{id:'first_win',name:'First Blood',desc:'Win your first official match',
chk:()=>G.resultsRecent.some(x=>x===1)},
{id:'first_trophy',name:'Silverware',desc:'Lift your first trophy',
chk:()=>Object.keys(G.achievements).filter(k=>k.startsWith('trophy_')).length>=1},
{id:'three_cups',name:'Dynasty Begins',desc:'Win three trophies',
chk:()=>Object.keys(G.achievements).filter(k=>k.startsWith('trophy_')).length>=3},
{id:'top10',name:'Elite Company',desc:'Reach the world TOP 10',
chk:()=>Game.rankOf(G.playerTeamId)<=10&&Game.rankOf(G.playerTeamId)>0},
{id:'top1',name:'Kings of the World',desc:'Become world #1',
chk:()=>Game.rankOf(G.playerTeamId)===1},
{id:'millionaire',name:'Seven Figures',desc:'Hold $1,000,000 in the bank',
chk:()=>G.money>=1000000},
{id:'star_signing',name:'Blockbuster',desc:'Field an 88+ rated superstar',
chk:()=>Game.teamPlayers(Game.me()).some(p=>playerOverall(p)>=88)},
{id:'academy_star',name:'Academy Graduate',desc:'Develop a scouted youth to 80+ overall',
chk:()=>Game.teamPlayers(Game.me()).some(p=>p.scouted&&playerOverall(p)>=80)},
{id:'fans_500k',name:'Global Brand',desc:'Reach 500,000 fans',
chk:()=>G.fans>=500000},
{id:'grand_slam',name:'Grand Slam Champion',desc:'Win 4 elite events within one year',
chk:()=>!!G.achievements.grandSlam},
],
checkMilestones(){
this.MILESTONES.forEach(m=>{
if(G.milestones.includes(m.id))return;
let ok=false;try{ok=m.chk();}catch(e){}
if(ok){
G.milestones.push(m.id);
this.pushNews('milestone',`🏆 Milestone unlocked: ${m.name}`,`${m.desc}. The organization grows ever stronger.`,'milestone');
}
});
},
ensureSquad(t){
if(!t)return;
let added=false;
let guard=0;
while(t.roster.length<5&&guard++<6){
const p=this.genYouth(t.id);t.roster.push(p.id);added=true;
if(t.isPlayer)this.pushNews('transfer',`Academy graduate: ${p.nick}`,
`${t.name} promote ${p.age}-year-old ${p.nick} (${p.role}, POT ${p.potential}) from the academy to fill the roster.`,'transfer');
}
const invalid=t.lineup.length<5||t.lineup.some(pid=>{const p=G.players[pid];return !p||p.teamId!==t.id||p.injuryDays>0&&!t.roster.some(x=>x!==pid&&G.players[x].injuryDays===0);});
if(invalid)this.autoLineup(t);
},
findUserFixtureToday(){
const me=this.me();
for(const ev of G.calendar){
if(ev.status!=='live')continue;
for(const rnd of ev.rounds)for(const m of rnd.matches){
if(m.day===G.day&&(m.a===me.id||m.b===me.id)&&!m.winner&&m.a&&m.b)return m;
}
}
return null;
},
weeklyTick(){
this.trainingWeekly();
this.moraleDrift();
this.contractCheck();
this.aiTransferWindow();
this.financesWeekly();
this.fansWeekly();
// rating decay
Object.values(G.teams).forEach(t=>{t.ratingPts=Math.max(150,t.ratingPts*0.996);});
this.computeRanking();
this.boardCheck();
if(G.day>=364*G.season){this.newSeason();}
},
monthlyTick(){
// salary payments happen weekly; monthly: sponsors pay
let inc=0;
G.sponsors.forEach(s=>inc+=s.monthly);
G.money+=inc;
if(inc>0)this.pushNews('finance',`Sponsor payments received`,`Partners paid out ${U.fmtM(inc)} this month.`,'finance');
// rent/ops cost
const ops=this.me().tier===4?30000:45000;
G.money-=ops;
// sponsor offers quarterly-ish
if(G.day%84===28)this.genSponsorOffers();
// AI squads occasionally shuffle lineups
Object.values(G.teams).forEach(t=>{if(!t.isPlayer&&U.chance(.25))this.autoLineup(t);});
},
/* ---------- training & development ---------- */
trainingWeekly(){
const me=this.me();
const coach=this.staffOf(me,'Coach'),physio=this.staffOf(me,'Physio');
const gains=[];
me.roster.forEach(pid=>{
const p=G.players[pid];
if(p.injuryDays>0)return;
const f=p.training.focus, inten=f==='Rest'?0:p.training.intensity;
if(f==='Rest'){
p.fatigue=U.clamp(p.fatigue-14-(physio?(physio.skill-60)*0.08:0),0,100);
p.morale=U.clamp(p.morale+2,0,100); p.form=U.clamp(p.form+0.2,0,10);
return;
}
if(f==='Media'){
G.fans+=U.ri(300,1400)*(this.staffOf(me,'Media Manager')?1.5:1)|0;
p.morale=U.clamp(p.morale+1,0,100); p.fatigue=U.clamp(p.fatigue-3,0,100);
return;
}
const attr=FOCUS_ATTR[f];
const gap=p.potential-playerOverall(p);
const youth=p.age<=21?1.5:p.age<=24?1.25:p.age<=27?1.0:p.age<=30?0.6:0.3;
const coachFac=coach?0.7+coach.skill/130:0.55;
const gainP=U.clamp(gap*0.010,0,0.4)*youth*coachFac*(inten===2?1.5:inten===1?1:0.6);
if(attr&&U.chance(gainP)){
const before=p.attrs[attr];
p.attrs[attr]=U.clamp(p.attrs[attr]+U.rnd(0.4,1.1)+(gap>25?0.5:0),30,99);
if(gains.length<5&&p.attrs[attr]>before+0.35){
gains.push(`${p.nick} +${(p.attrs[attr]-before).toFixed(1)} ${f.toLowerCase()}`);
}
}
// all attrs tiny drift with hard work for youngsters
if(p.age<=23&&U.chance(0.05)){const k=U.pick(Object.keys(p.attrs));p.attrs[k]=U.clamp(p.attrs[k]+0.3,30,99);}
p.form=U.clamp(p.form+(inten===2?0.15:-0.05)+U.gauss(0,0.3),0.5,10);
p.fatigue=U.clamp(p.fatigue+inten*7-(physio?(physio.skill-60)*0.06:0),0,100);
if(f==='Teamwork'){me.fam=U.clamp(me.fam+1.2,0,100);}
// injury risk
if(p.fatigue>86&&U.chance(0.10)){
p.injuryDays=U.ri(7,21); p.fatigue=60;
this.pushNews('medical',`${p.nick} injured`,`${p.nick} overstressed ${attr==='aim'?'aim routines':'in practice'} and will be out ~${p.injuryDays} days (overuse injury).`,'medical');
}
});
me.fam=U.clamp(me.fam+1,0,100);
if(gains.length){
this.pushNews('training','📈 Weekly training report',
`Notable gains this week: ${gains.join(' · ')}. Consistent focus + a quality coach compounds over a season.`,'training');
}
},
setTraining(pid,focus,intensity){
const p=this.p(pid);
if(focus!==undefined)p.training.focus=focus;
if(intensity!==undefined)p.training.intensity=intensity;
},
bootcamp(){
const me=this.me();
if(G.flags.bootcampUntil>G.day)return false;
if(G.money<20000)return 'nomoney';
G.money-=20000;
G.flags.bootcampUntil=G.day+7;
me.fam=U.clamp(me.fam+8,0,100);
me.lineup.forEach(pid=>{const p=this.p(pid);p.morale=U.clamp(p.morale+4,0,100);p.attrs.iq=U.clamp(p.attrs.iq+U.rnd(0,0.8),30,99);});
this.pushNews('training','Bootcamp complete','A week of intense practice together improved team chemistry and tactical understanding.',null);
return true;
},
/* ---------- morale / psychology ---------- */
moraleDrift(){
const psy=this.staffOf(this.me(),'Psychologist');
Object.values(G.players).forEach(p=>{
if(p.teamId===null)return;
let base=55+(p.personality.prof-50)*0.1;
const t=G.teams[p.teamId];
const playing=t.lineup.includes(p.id);
if(!playing)base-=12; else base+=4;
if(p.promise){
base+=p.promise.broken?-15:8;
if(G.day>p.promise.until){
if(p.promise.broken){p.morale=U.clamp(p.morale-18,0,100);
this.pushNews('talk',`${p.nick} is frustrated`,`${p.nick} feels the promise made to him was broken. Morale dropped heavily.`,'talk');
}
p.promise=null;
}
}
if(p.salary>0){
const fair=wageDemand(p,G);
if(p.salary<fair*0.6)base-=8;
}
const spd=psy?0.30:0.18;
p.morale=U.clamp(p.morale+(base-p.morale)*spd+U.gauss(0,2),0,100);
p.motivation=U.clamp(p.motivation+( (playing?62:48)-p.motivation)*0.15+U.gauss(0,2),10,100);
});
},
applyTalk(pid,topic,tone){
const p=this.p(pid);
if(p.talkCooldown>0)return {ok:false,msg:`${p.nick} needs space — try again in ${p.talkCooldown} days.`};
p.talkCooldown=10;
const prof=p.personality.prof, temper=p.personality.temper, amb=p.personality.ambition;
let dM=0,dMo=0,txt='',ok=true;
const toneF={supportive:0,demanding:1,neutral:0.5}[tone];
switch(topic){
case 'praise':
ok=p.form>=4.5; txt= ok?`${p.nick} appreciated the recognition.`:`${p.nick} knows his form is poor — empty praise annoyed him.`;
dM= ok?6+toneF*2:-4; break;
case 'motivate':
dMo=(tone==='demanding'? (temper>60? -8: 10):(amb>60?8:4));
ok=dMo>0; txt= ok?`${p.nick} looks fired up for the upcoming matches.`:`${p.nick} reacted badly to pressure. Motivation dropped.`;
break;
case 'role':
ok=U.chance(0.5+prof/200);
txt= ok?`${p.nick} accepts his current role in the lineup.`:`${p.nick} wants more importance in the team. Handle with care.`;
dM= ok?4:-8; break;
case 'discipline':
dM=-6-toneF*6; dMo=tone==='demanding'?6:-2;
txt=`You had a serious word about professionalism. ${temper>70?'It got tense — he pushed back.':'He took it professionally.'}`;
if(temper<=70)p.personality.prof=U.clamp(prof+2,0,99);
break;
case 'promise':
return this.makePromise(p);
case 'contract':
txt=`${p.nick} wants to be paid like a top player if he performs like one. His agent noted your interest.`;
dM=2; break;
}
p.morale=U.clamp(p.morale+dM,0,100);
p.motivation=U.clamp(p.motivation+dMo,0,100);
if(dM>0)p.relationship=(p.relationship||50)+3; if(dM<0)p.relationship=(p.relationship||50)-5;
return {ok,msg:txt,dM,dMo};
},
makePromise(p){
if(p.promise){return {ok:false,msg:`You already have an open promise with ${p.nick}.`};}
p.promise={type:'playtime',broken:false,until:G.day+56};
p.morale=U.clamp(p.morale+10,0,100); p.motivation=U.clamp(p.motivation+12,0,100);
this.pushNews('talk',`Promise made to ${p.nick}`,`You promised ${p.nick} a starter spot for the next 8 weeks. Break it and face the consequences.`,'talk');
return {ok:true,msg:`Promise made: starter for 8 weeks. ${p.nick} is motivated — don't betray him.`};
},
checkPromisesAfterLineup(){
this.me().roster.forEach(pid=>{
const p=this.p(pid);
if(p.promise&&!p.promise.broken){
const playing=this.me().lineup.includes(pid);
if(!playing){p.promise.broken=true;}
}
});
},
/* ---------- contracts ---------- */
contractCheck(){
const warn=d=>d===60||d===30||d===7;
Object.values(G.players).forEach(p=>{
if(p.teamId===null||p.loanFrom)return;
const left=p.contractUntil-G.day;
const t=G.teams[p.teamId];
if(warn(left)&&t.isPlayer){
this.pushNews('contract',`${p.nick}'s contract expires in ${left} days`,
`${p.nick} (${playerOverall(p).toFixed(0)} OVR, wage demand ~${U.fmtM(wageDemand(p,G))}/mo) will become a FREE AGENT. Renew now or risk losing him for nothing.`,'contract',{action:{kind:'renew',pid:p.id}});
}else if(warn(left)&&!t.isPlayer&&U.chance(.5)){
// AI renews most
p.contractUntil=G.day+U.ri(365,900); p.salary=this._aiSalary(p);
}
if(left<=0){
if(t.isPlayer){
G.freeAgents.push(p.id); p.teamId=null; p.salary=0; p.contractUntil=0;
t.roster=t.roster.filter(x=>x!==p.id); t.lineup=t.lineup.filter(x=>x!==p.id);
this.pushNews('transfer',`${p.nick} left the club`,
`${p.nick}'s contract expired and he signed nowhere yet — he is now a free agent. You receive nothing.`,'transfer');
}else{
if(U.chance(.72)){p.contractUntil=G.day+U.ri(365,900);}
else{
t.roster=t.roster.filter(x=>x!==p.id); t.lineup=t.lineup.filter(x=>x!==p.id);
G.freeAgents.push(p.id); p.teamId=null;p.salary=0;p.contractUntil=0;
if(U.chance(.4))this.pushNews('transfer',`${p.nick} is a free agent`,`Contract expired — ${p.nick} hit the open market.`,'transfer');
}
}
}
});
},
renewContract(pid,salary,years,bonus=0){
const p=this.p(pid), me=this.me();
const demand=wageDemand(p,G);
const ratio=salary/Math.max(demand,1);
let acc=U.logistic((ratio-1)*6+(bonus>0?0.4:0)+(p.morale-60)/150+(p.age>29?-0.3:0.1));
if(years>3&&p.age>28)acc-=0.15;
if(ratio<0.75)acc*=0.25;
if(G.money<bonus){return {ok:false,msg:'Not enough money for the signing bonus.'};}
if(U.chance(acc)){
G.money-=bonus; p.salary=salary; p.contractUntil=G.day+years*364;
p.morale=U.clamp(p.morale+ (ratio>=1?8:3),0,100);
this.pushNews('contract',`${p.nick} signs a new deal`,
`${p.nick} extended for ${years} year${years>1?'s':''} at ${U.fmtM(salary)}/mo${bonus?` (+${U.fmtM(bonus)} signing bonus)`:''}.`,'contract');
return {ok:true,msg:`${p.nick} put pen to paper! 🖊️`};
}
p.morale=U.clamp(p.morale-3,0,100);
const counter=Math.round(demand*1.1/500)*500;
return {ok:false,msg:`${p.nick}'s camp rejected the offer. They want ~${U.fmtM(counter)}/mo.`};
},
releasePlayer(pid){
const p=this.p(pid),me=this.me();
const comp=p.salary*Math.max(1,(p.contractUntil-G.day)/30)*0.5;
if(G.money<comp)return {ok:false,msg:`Severance would cost ${U.fmtM(comp)} — not enough funds.`};
G.money-=comp; me.roster=me.roster.filter(x=>x!==pid); me.lineup=me.lineup.filter(x=>x!==pid);
p.teamId=null;p.salary=0;p.contractUntil=0;G.freeAgents.push(pid);
this.checkPromisesAfterLineup();
this.pushNews('transfer',`${p.nick} released`,`Contract terminated. Severance paid: ${U.fmtM(comp)}.`,'transfer');
return {ok:true};
},
/* ---------- transfers ---------- */
listPlayer(pid,price){this.p(pid).listedPrice=price;},
unlistPlayer(pid){this.p(pid).listedPrice=null;},
signFreeAgent(pid,salary,years,bonus=0){
const p=this.p(pid),me=this.me();
if(me.roster.length>=8)return {ok:false,msg:'Roster is full (max 8). Sell or release someone first.'};
const demand=wageDemand(p,G);
const ratio=salary/Math.max(demand,1);
let acc=U.logistic((ratio-1)*5+(p.morale-50)/200);
const rank=this.rankOf(me.id)||60;
acc+=U.clamp((60-rank)*0.004,-0.15,0.2);
if(ratio<0.8)acc*=0.3;
if(G.money<bonus)return {ok:false,msg:'Not enough money for the signing bonus.'};
if(U.chance(acc)){
G.money-=bonus;
p.teamId=me.id;p.salary=salary;p.contractUntil=G.day+years*364;p.listedPrice=null;p.loanFrom=undefined;
G.freeAgents=G.freeAgents.filter(x=>x!==pid);
me.roster.push(pid);
me.fam=U.clamp(me.fam-6,0,100);
p.morale=U.clamp(p.morale+5,0,100);
this.pushNews('transfer',`${p.nick} joins ${me.name}!`,
`Free agent signing: ${playerOverall(p).toFixed(0)} OVR ${p.role} on a ${years}-year deal at ${U.fmtM(salary)}/mo.`,'transfer');
return {ok:true,msg:`Welcome to the club, ${p.nick}! 🎉`};
}
return {ok:false,msg:`${p.nick} declined — he expects around ${U.fmtM(Math.round(demand/500)*500)}/mo${rank>25?' (and doubts our project…)':'.'}`};
},
makeTransferOffer(pid,fee,salary,years){
const p=this.p(pid),me=this.me();
const seller=G.teams[p.teamId];
if(!seller||seller.isPlayer)return {ok:false,msg:'Invalid target.'};
if(me.roster.length>=8)return {ok:false,msg:'Roster is full (max 8).'};
const val=playerValue(p,G);
let acc=U.logistic((fee/Math.max(val,1)-1.15)*5);
const wdemand=wageDemand(p,G);
acc+=U.logistic((salary/wdemand-1)*4)*0.3-0.15;
const myRank=this.rankOf(me.id)||60, theirRank=this.rankOf(seller.id)||60;
if(theirRank<myRank)acc-=0.2; // they won't sell down easily unless $$$
if(U.chance(acc)){
if(G.money<fee)return {ok:false,msg:'Insufficient funds.'};
G.money-=fee;
seller.roster=seller.roster.filter(x=>x!==pid); seller.lineup=seller.lineup.filter(x=>x!==pid);
this.autoLineup(seller); seller.fam=U.clamp(seller.fam-15,0,100);
p.teamId=me.id;p.salary=salary;p.contractUntil=G.day+years*364;p.listedPrice=null;
me.roster.push(pid); me.fam=U.clamp(me.fam-8,0,100);
this.pushNews('transfer',`${p.nick} transferred to ${me.name}`,
`Fee: ${U.fmtM(fee)} • Wage ${U.fmtM(salary)}/mo • ${years}y. The scene reacts to the blockbuster move.`,'transfer');
return {ok:true,msg:`Transfer complete! ${p.nick} is yours. 💰`};
}
const ask=Math.round(val*(theirRank<this.rankOf(me.id)?1.5:1.2)/50000)*50;
return {ok:false,msg:`${seller.tag} rejected the offer. Indication: ~${U.fmtM(ask)}.`};
},
sendOnLoan(pid,targetId){
const p=this.p(pid),me=this.me(),target=G.teams[targetId];
if(!p||!target||target.isPlayer)return {ok:false,msg:'Invalid loan destination.'};
if(p.injuryDays>0)return {ok:false,msg:`${p.nick} is injured and cannot be loaned out.`};
if(me.roster.length-1<5)return {ok:false,msg:'You must keep at least 5 players on the roster.'};
if(p.loanFrom)return {ok:false,msg:'Player is already on loan.'};
p.loanFrom=me.id; p.loanDaysLeft=90; p.teamId=targetId;
me.roster=me.roster.filter(x=>x!==pid); me.lineup=me.lineup.filter(x=>x!==pid);
target.roster.push(pid); this.autoLineup(target);
const fee=Math.round(playerValue(p,G)*0.04/1000)*1000;
G.money+=fee;
this.pushNews('transfer',`${p.nick} loaned to ${target.name}`,`Development loan (90 days) for ${U.fmtM(fee)}. He'll get playing time there.`,'transfer');
return {ok:true};
},
endLoan(p){
const cur=G.teams[p.teamId],home=G.teams[p.loanFrom];
cur.roster=cur.roster.filter(x=>x!==p.id);cur.lineup=cur.lineup.filter(x=>x!==p.id);
p.teamId=p.loanFrom;p.loanFrom=undefined;p.loanDaysLeft=undefined;
if(home){home.roster.push(p.id);this.autoLineup(home);
this.pushNews('transfer',`${p.nick} returned from loan`,`${p.nick} is back in the ${home.name} squad after his development spell.`,'transfer');}
},
/* transfer windows: two per season (post-spring ~day150-230, winter ~300-364) */
inTransferWindow(){
const d=((G.day-1)%364)+1;
return (d>=150&&d<=230)||(d>=300);
},
aiTransferWindow(){
if(!this.inTransferWindow())return;
const aggro=G.diff.aiAggro;
if(!U.chance(0.5*aggro))return;
const aiTeams=Object.values(G.teams).filter(t=>!t.isPlayer&&t.roster.length>3);
const buyer=U.pick(aiTeams);
// candidate: free agents or listed players better than buyer's weakest starter
const starters=buyer.lineup.map(id=>G.players[id]);
const weak=U.avg(starters.map(playerOverall));
const pool=[
...G.freeAgents.map(id=>G.players[id]),
...Object.values(G.players).filter(p=>p.listedPrice&&G.teams[p.teamId]&&!G.teams[p.teamId].isPlayer),
...Object.values(G.players).filter(p=>!G.teams[p.teamId]||(!G.teams[p.teamId].isPlayer&&!p.listedPrice&&U.chance(0.06))),
].filter(p=>p&&playerOverall(p)>weak+3&&(!p.loanFrom)&&p.teamId!==buyer.id);
if(!pool.length)return;
const target=U.pickW(pool,p=>playerOverall(p));
const from=G.teams[target.teamId];
if(from&&from.isPlayer){
// bid on YOUR player → inbox offer
if(target.listedPrice===null&&U.chance(.5))return;
const fee=target.listedPrice??Math.round(playerValue(target,G)*U.rnd(0.9,1.3)/50000)*50;
const wage=Math.round(wageDemand(target,G)*U.rnd(1.05,1.35)/500)*500;
this.pendingOffers.push({pid:target.id,from:buyer.id,fee,wage,expires:G.day+10});
this.pushNews('offer',`${buyer.name} bid for ${target.nick}`,
`${buyer.tag} offered ${U.fmtM(fee)} (his value: ${U.fmtM(playerValue(target,G))}) with wages of ${U.fmtM(wage)}/mo. Respond in the Inbox or Squad screen.`,'offer',
{action:{kind:'bid'}});
}else if(from){
const fee=Math.round(playerValue(target,G)*U.rnd(1.0,1.35)/50000)*50;
from.roster=from.roster.filter(x=>x!==target.id);from.lineup=from.lineup.filter(x=>x!==target.id);
this.autoLineup(from);
target.teamId=buyer.id;target.salary=this._aiSalary(target);target.listedPrice=null;
buyer.roster.push(target.id);this.autoLineup(buyer);
buyer.fam=U.clamp(buyer.fam-10,0,100);from.fam=U.clamp(from.fam-10,0,100);
this.pushNews('rumor',`TRANSFER: ${target.nick}${buyer.tag}`,
`${buyer.name} complete the signing of ${target.nick} (${playerOverall(target).toFixed(0)} OVR) from ${from.name} for a reported ${U.fmtM(fee)}.`,'rumor');
}else{
// FA signs
target.teamId=buyer.id;target.salary=this._aiSalary(target);target.contractUntil=G.day+U.ri(365,730);
G.freeAgents=G.freeAgents.filter(x=>x!==target.id);
buyer.roster.push(target.id);this.autoLineup(buyer);
}
},
respondBid(i,accept){
const b=G.pendingOffers[i]; if(!b)return;
const p=this.p(b.pid),me=this.me();
G.pendingOffers.splice(i,1);
if(accept){
G.money+=b.fee;
me.roster=me.roster.filter(x=>x!==b.pid);me.lineup=me.lineup.filter(x=>x!==b.pid);
const buyer=G.teams[b.from];
p.teamId=buyer.id;p.salary=b.wage;p.listedPrice=null;p.promise=null;
buyer.roster.push(b.pid);this.autoLineup(buyer);
this.checkPromisesAfterLineup();
me.fam=U.clamp(me.fam-10,0,100);
this.pushNews('transfer',`${p.nick} sold to ${buyer.name}`,`Deal done: ${U.fmtM(b.fee)} banked. Reinvest wisely.`,'transfer');
}else{
this.pushNews('offer',`Bid rejected`,`${this.me().name} turned down ${G.teams[b.from].tag}'s approach for ${p.nick}.`,'offer');
}
},
/* ---------- finances ---------- */
financesWeekly(){
const me=this.me();
let wages=0;
me.roster.forEach(pid=>wages+=G.players[pid].salary/4.33);
Object.values(me.staff).forEach(sid=>{const s=G.staff[sid];if(s)wages+=s.salary/4.33;});
G.money-=Math.round(wages);
// merch & streaming
const media=this.staffOf(me,'Media Manager');
const merch=G.fans*0.05*(media?1+media.skill/200:1);
G.money+=merch;
this.lastWeeklyWages=Math.round(wages);this.lastWeeklyMerch=Math.round(merch);
if(G.money<-400000&&!G.flags.debtWarned){
G.flags.debtWarned=true;
this.pushNews('finance','⚠️ CRITICAL DEBT','The account is deep in red. Sell players, cut wages, or the board will force austerity.','finance');
}
if(G.money>0)G.flags.debtWarned=false;
const fin=this.staffOf(me,'Finance Director');
if(fin&&U.chance(0.2)){G.money+=Math.round(fin.skill*40);} // finds savings
},
fansWeekly(){
const me=this.me();
const media=this.staffOf(me,'Media Manager');
let growth=G.rep*2+(media?(media.skill-60)*8:0);
growth*=U.rnd(0.7,1.3);
G.fans=Math.max(500,Math.round(G.fans+growth));
},
genSponsorOffers(){
const me=this.me();const rank=this.rankOf(me.id)||50;
const fin=this.staffOf(me,'Finance Director');
G.sponsorOffers=[];
for(let i=0;i<3;i++){
const brand=U.pick(SPONSOR_BRANDS.filter(b=>!G.sponsors.some(s=>s.brand===b)));
const type=U.pick(SPONSOR_TYPES);
const quality=U.clamp(95-rank*1.1+G.rep*0.4+U.gauss(0,8),10,120);
const monthly=Math.round((quality*quality*3.6)*(fin?1+fin.skill/400:1)/500)*500;
G.sponsorOffers.push({id:U.uid(),brand,type,monthly,months:U.ri(6,18),req:rank});
}
this.pushNews('sponsor','New sponsorship proposals',`${G.sponsorOffers.length} brands tabled offers. Review them in Finances.`,'sponsor',{action:{kind:'sponsor'}});
},
acceptSponsor(id){
const off=G.sponsorOffers.find(o=>o.id===id);if(!off)return false;
if(G.sponsors.length>=3)return false;
G.sponsors.push({...off,until:G.day+off.months*28});
G.sponsorOffers=G.sponsorOffers.filter(o=>o.id!==id);
this.pushNews('sponsor',`${off.brand} partnership signed`,`${off.type} deal worth ${U.fmtM(off.monthly)}/mo for ${off.months} months. Welcome aboard!`,'sponsor');
return true;
},
rejectSponsor(id){G.sponsorOffers=G.sponsorOffers.filter(o=>o.id!==id);},
tickSponsors(){
G.sponsors=G.sponsors.filter(s=>{
if(G.day>s.until){this.pushNews('sponsor',`${s.brand} deal expired`,`The partnership with ${s.brand} ran its course.`,'sponsor');return false;}
return true;
});
},
/* ---------- board ---------- */
boardCheck(){
const me=this.me();
const strict=G.diff.boardStrict;
const recent=G.resultsRecent||(G.resultsRecent=[]);
const wr=recent.length?U.avg(recent)/1:0.5;
const expect=me.tier===1?0.52:me.tier===2?0.47:0.43;
let delta=(wr-expect)*10*strict + (G.money>0?0.7:-0.9)*strict;
G.board=U.clamp(G.board+U.clamp(delta,-3,3),0,100);
if(G.board<25&&!G.flags.boardWarned){G.flags.boardWarned=true;
this.pushNews('board','⚠️ Board is losing patience','Results and/or finances are below expectations. The board demands improvement soon.','board');}
if(G.board>=40)G.flags.boardWarned=false;
},
recordResult(win){
G.resultsRecent=G.resultsRecent||[];
G.resultsRecent.push(win?1:0);
if(G.resultsRecent.length>10)G.resultsRecent.shift();
},
/* ---------- tournaments ---------- */
registerEvent(evId){
const ev=G.calendar.find(e=>e.id===evId);if(!ev)return;
if(ev.status!=='open'||ev.registered)return;
const me=this.me();
const rank=this.rankOf(me.id)||99;
const cutoff=ev.tier===1?20:ev.tier===2?40:999;
if(rank>cutoff&&ev.tier<3){
// wildcard lottery chance
if(!U.chance(U.clamp(0.9-(rank-cutoff)*0.06,0.05,0.9))){
return {ok:false,msg:`Ranking #${rank} is outside the invite zone (top ${cutoff}). Try lower tiers.`};
}
}
ev.registered=true;
this.pushNews('event',`Registered: ${ev.name}`,`${me.name} will compete in ${ev.name} (starts ${U.fmtDate(ev.startDay)}).`,'event');
return {ok:true};
},
lockEvents(){
const me=this.me();
G.calendar.forEach(ev=>{
if(ev.status!=='open')return;
if(G.day>=ev.startDay-14){
// build field
let field=[];
if(ev.registered)field.push(me.id);
const others=Object.values(G.teams).filter(t=>t.id!==me.id)
.sort((a,b)=>b.ratingPts-a.ratingPts);
field.push(...others.slice(0,ev.slots-field.length).map(t=>t.id));
if(field.length<4)field.push(...others.slice(ev.slots,ev.slots+4-field.length).map(t=>t.id));
// trim to power of two for a clean bracket
const nPow=Math.max(4,Math.pow(2,Math.floor(Math.log2(Math.min(field.length,16)))));
ev.teams=field.slice(0,nPow);
ev.status='locked';
if(ev.teams.includes(me.id))
this.pushNews('event',`${ev.name} — final team list`,
`${ev.name} field is set (${ev.teams.length} teams, ${U.fmtM(ev.prize)} to the winner). Event starts ${U.fmtDateShort(ev.startDay)}.`,'event');
}
});
},
tickEvent(ev){
this.lockEvents();
if(ev.status==='locked'&&G.day>=ev.startDay){
ev.status='live';
this.buildBracket(ev);
this.pushNews('event',`${ev.name} begins!`,`${ev.teams.length} teams fight for ${U.fmtM(ev.prize)}${ev.major?' and Major glory':''}.`,'event');
}
if(ev.status==='live'){
// cascade: fill next rounds when ready, then play everything due (past or today)
for(let iter=0;iter<12;iter++){
let progressed=false;
ev.rounds.forEach(rnd=>{
const i=ev.rounds.indexOf(rnd);
if(i>0&&!rnd.filled){
const prev=ev.rounds[i-1];
if(prev.matches.every(m=>m.winner)){
this.advanceRound(ev,prev);
rnd.filled=true;
if(!rnd.matches[0].a)rnd.filled=false; // safety
progressed=true;
}
}
});
ev.rounds.forEach(rnd=>{
rnd.matches.forEach(m=>{
if(m.day<=G.day&&!m.winner&&m.a&&m.b&&(m.a!==G.playerTeamId&&m.b!==G.playerTeamId)){
MatchEngine.simulate(m,ev);progressed=true;
}
});
});
if(!progressed)break;
}
if(ev.rounds.length&&ev.rounds.at(-1).matches.every(m=>m.winner)){
this.finishEvent(ev);
}
}
this.tickSponsors();
},
buildBracket(ev){
const teams=U.shuffle(ev.teams.slice());
const n=teams.length;
const offs=ev.days>=9?[0,2,4,ev.days-3]:ev.days>=7?[0,2,3,ev.days-2]:[0,1,2,3];
let names;
if(n>=16)names=['Round of 16','Quarterfinal','Semifinal','Grand Final'];
else if(n>=8)names=['Quarterfinal','Semifinal','Grand Final'];
else names=['Semifinal','Grand Final'];
let idx=0;
for(let r=0;r<names.length;r++){
const cnt=n/Math.pow(2,r+1); // R16=8, QF=4, SF=2, F=1
const matches=[];
for(let i=0;i<cnt;i++){
// only the opening round receives real teams; later rounds are filled by advanceRound
const hasTeams=r===0;
matches.push({id:U.uid(),a:hasTeams?teams[idx++]:null,b:hasTeams?teams[idx++]:null,
winner:null,score:'',day:ev.startDay+offs[r],
bo:r===names.length-1?5:(names.length===4&&r===0?1:3),maps:[]});
}
ev.rounds.push({name:names[r],day:ev.startDay+offs[r],matches,advanced:false});
}
},
advanceRound(ev,rnd){
const i=ev.rounds.indexOf(rnd);
const nxt=ev.rounds[i+1];if(!nxt)return;
const winners=rnd.matches.map(m=>m.winner);
nxt.matches.forEach((m,j)=>{m.a=winners[j*2];m.b=winners[j*2+1];});
},
finishEvent(ev){
ev.status='done';
const final=ev.rounds.at(-1).matches[0];
const champ=final.winner, runner=final.winner===final.a?final.b:final.a;
ev.champion=champ;
const sfLosers=ev.rounds.at(-2).matches.map(m=>m.winner===m.a?m.b:m.a);
const qfLosers=ev.rounds.at(-3)?ev.rounds.at(-3).matches.map(m=>m.winner===m.a?m.b:m.a):[];
const places=[champ,runner,...sfLosers,...qfLosers];
places.forEach((tid,i)=>{
const fr=i<PRIZE_FR.length?PRIZE_FR[i]:0.03;
const pf=i<PTS_FR.length?PTS_FR[i]:0.08;
const t=G.teams[tid];if(!t)return;
const money=Math.round(ev.prize*fr),pts=Math.round(ev.pts*pf*(ev.major?1.4:1));
t.ratingPts+=pts;t._lastPrize=money;t._lastPts=pts;
if(t.isPlayer){
G.money+=money;
if(money>0)this.pushNews('finance',`${U.esc(ev.name)}: ${U.fmtM(money)} prize money`,
`Placement earnings banked. Current balance: ${U.fmtM(G.money)}.`,'finance');
}
});
const ct=G.teams[champ];
this.pushNews('result',`🏆 ${ct.name} win ${ev.name}!`,
`${ct.tag} defeated ${G.teams[runner].tag} in the grand final. ${U.fmtM(ev.prize)} and ${ev.pts} ranking points to the champions.`,'result');
if(ct.isPlayer){
G.rep=U.clamp(G.rep+ (ev.major?8:ev.tier===1?5:2),0,100);
G.fans+=U.ri(20000,60000);
G.achievements[`trophy_${ev.id}`]={ev:ev.name,day:G.day};
if(ev.gs){
G.grandSlamWins.push({ev:ev.id,day:G.day});
const recent=G.grandSlamWins.slice(-GS_REQUIRED);
if(recent.length>=GS_REQUIRED&&(recent.at(-1).day-recent[0].day)<=GS_WINDOW_DAYS){
G.money+=GS_BONUS;G.achievements.grandSlam={day:G.day};
this.pushNews('result','💎 GRAND SLAM COMPLETE!',
`By winning ${GS_REQUIRED} elite events in a row you completed the GRAND SLAM and banked a ${U.fmtM(GS_BONUS)} bonus. Legendary.`,'result');
}
}
}else if(G.teams[runner].isPlayer){
G.rep=U.clamp(G.rep+ (ev.major?5:3),0,100);
}
},
/* called by match engine after any match resolves */
finishMatch(m,res){
m.winner=res.winner;m.score=res.score;m.maps=res.maps;m.box=res.box;
const loser=res.winner===m.a?m.b:m.a;
const wTeam=G.teams[res.winner],lTeam=G.teams[loser];
// board tracks the user's own form only
if(wTeam.isPlayer)this.recordResult(true);
else if(lTeam.isPlayer)this.recordResult(false);
// player stats & fatigue & form
const apply=(tid,won)=>{
const t=G.teams[tid];
t.lineup.forEach(pid=>{
const p=G.players[pid];if(!p)return;
const st=res.box[pid]||{k:0,d:0,adr:40,ratingSum:1,mapsN:1};
const rt=st.rating!==undefined?st.rating:(st.ratingSum||1)/Math.max(st.mapsN||1,1);
p.stats.maps++;p.seasonStats.maps++;
p.stats.kills+=st.k;p.seasonStats.kills+=st.k;
p.stats.deaths+=st.d;p.seasonStats.deaths+=st.d;
p.stats.adr=(p.stats.adr*(p.stats.maps-1)+st.adr)/p.stats.maps;
p.seasonStats.adr=p.stats.adr;
p.stats.ratingSum+=rt;p.seasonStats.ratingSum+=rt;
if(st.mvp)p.seasonStats.mvp++;
if(st.aces)p.seasonStats.aces+=st.aces;
p.fatigue=U.clamp(p.fatigue+U.rnd(6,11),0,100);
p.form=U.clamp(p.form+(rt>1.1?0.5:rt<0.85?-0.4:0.1)+ (won?0.25:-0.2),0.5,10);
p.morale=U.clamp(p.morale+(won?2.5:-2.5)+(rt>1.2?2:rt<0.8?-2:0),0,100);
});
};
apply(res.winner,true);apply(loser,false);
const sw=U.ri(8,20);
wTeam.ratingPts+=sw;lTeam.ratingPts=Math.max(100,lTeam.ratingPts-sw);
// post-match news for user matches
if(wTeam.isPlayer||lTeam.isPlayer){
const meIsWin=wTeam.isPlayer;
const opp=meIsWin?lTeam:wTeam;
this.pushNews('match',`${meIsWin?'✅ Victory':'❌ Defeat'} vs ${opp.tag} (${res.score})`,
`${meIsWin?'We':'We'} ${res.score} against ${opp.name}.${res.note||''}`,'match');
}
},
/* ---------- seasons ---------- */
newSeason(){
const rank=this.rankOf(G.playerTeamId)||99;
G.history.push({season:G.season,rank,money:G.money,fans:G.fans,trophies:Object.keys(G.achievements).length});
G.season++;
Object.values(G.players).forEach(p=>{
p.age++;
p.stats={...this.zeroStats()};
});
// extend calendar next year
const shift=364*(G.season-1);
CAL_2026.forEach((e,i)=>{
if(G.calendar.some(x=>x.baseId==='e'+i&&x.seasonNo===G.season))return;
G.calendar.push({id:'e'+i+'y'+G.season,baseId:'e'+i,...e,startDay:e.day+shift,
seasonNo:G.season,status:'open',teams:[],champion:null,rounds:[],registered:false,userOut:false});
});
// NOTE: finished events are kept forever — they hold the user's match history
this.pushNews('season',`🎉 Season ${G.season-1} review`,
`Final world ranking: #${rank}. Balance: ${U.fmtM(G.money)}. A new season of events is published — chase greatness.`,'board');
this.computeRanking();
},
/* ---------- save/load ---------- */
serialize(){return JSON.stringify(G);},
staticLoad(json){
const obj=JSON.parse(json);
if(!obj||obj.ver!==1)throw new Error('bad save');
G=obj;G.diff=DIFFS[obj.diffKey];
return true;
},
};
/* tiny save facade (localStorage guarded for node tests) */
const Save={
store(){
try{return (typeof localStorage!=='undefined')?localStorage:null;}catch(e){return null;}
},
save(auto){
const ls=this.store();if(!ls||!G)return;
try{ls.setItem(SAVE_KEY,Game.serialize());if(auto)ls.setItem(SAVE_KEY+'_ts',String(Date.now()));}catch(e){}
},
exists(){const ls=this.store();if(!ls)return false;try{return !!ls.getItem(SAVE_KEY);}catch(e){return false;}},
load(){const ls=this.store();if(!ls)return false;try{Game.staticLoad(ls.getItem(SAVE_KEY));return true;}catch(e){return false;}},
wipe(){const ls=this.store();if(ls){try{ls.removeItem(SAVE_KEY);}catch(e){}}},
};