- 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)
1031 lines
46 KiB
JavaScript
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){}}},
|
|
};
|