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:
deepseek
2026-08-23 07:00:47 +00:00
commit 4c7fe1db4c
15 changed files with 4722 additions and 0 deletions
+297
View File
@@ -0,0 +1,297 @@
'use strict';
/* ================= static world data ================= */
/* player tuple: [nick, real, age, nat, role, [aim,pos,clutch,util,iq,move,comm], potential] */
const MAPS=[
{name:'Mirage', bias:-0.5},
{name:'Inferno', bias:-0.5},
{name:'Nuke', bias: 2.0},
{name:'Ancient', bias: 0.5},
{name:'Anubis', bias: 0.0},
{name:'Dust II', bias:-1.0},
{name:'Train', bias: 1.5},
];
const ACTIVE_MAPS=MAPS.map(m=>m.name);
const TEAMS_DATA=[
/* ---- TIER 1 ---- */
{name:'Natus Vincere',tag:'NAVI',color:'#ffe600',tier:1,region:'CIS',roster:[
['Aleksib','Aleksi Virolainen',29,'FI','IGL',[72,80,62,86,93,72,93],92],
['iM','Mihai Ivan',27,'RO','Lurker',[88,86,80,74,84,84,80],89],
['b1t','Valeriy Vakhovskiy',23,'UA','Rifler',[91,86,82,80,86,86,82],92],
['jL','Justinas Lekavicius',30,'LT','Entry',[87,84,80,70,80,86,80],86],
['w0nderful','Ihor Zhdanov',21,'UA','AWP',[90,88,80,74,86,84,80],95]]},
{name:'Team Vitality',tag:'VIT',color:'#f7e017',tier:1,region:'Europe',roster:[
['apEX','Dan Madesclaire',33,'FR','IGL',[76,82,70,88,89,74,93],88],
['flameZ','Shahar Shushan',22,'IL','Entry',[89,82,82,74,84,88,80],91],
['mezii','William Merriman',27,'GB','Support',[82,84,78,88,86,80,86],87],
['ropz','Robin Kool',26,'EE','Lurker',[92,94,87,80,92,88,82],95],
['ZywOo','Mathieu Herbaut',25,'FR','AWP',[98,95,92,80,90,94,82],99]]},
{name:'Team Spirit',tag:'SPRT',color:'#e03a3e',tier:1,region:'CIS',roster:[
['chopper','Leonid Vishnyakov',28,'RU','IGL',[78,82,72,84,90,76,88],89],
['donk','Danil Kryshkovets',19,'RU','Entry',[97,84,88,68,82,96,74],99],
['magixx','Boris Vorobiev',23,'RU','Support',[82,84,78,86,84,80,84],86],
['zont1x','Myroslav Plakhotia',21,'UA','Lurker',[87,86,80,78,86,84,80],90],
['sh1ro','Dmitry Sokolov',24,'RU','AWP',[93,97,85,76,88,84,80],96]]},
{name:'MOUZ',tag:'MOUZ',color:'#e30613',tier:1,region:'Europe',roster:[
['Brollan','Ludvig Brolin',24,'SE','Rifler',[87,84,80,80,84,84,82],89],
['torzsi','Ádám Torzsás',21,'HU','AWP',[89,88,78,72,84,82,80],93],
['Jimpphat','Kristian Saarelainen',20,'FI','Entry',[89,80,82,70,82,90,78],92],
['xertioN','Dorian Berman',22,'IL','Rifler',[87,84,80,78,84,86,80],90],
['spinx','Lotan Giladi',25,'IL','Lurker',[89,88,84,80,86,84,80],92]]},
{name:'FaZe Clan',tag:'FAZE',color:'#ff0000',tier:1,region:'Europe',roster:[
['karrigan','Finn Andersen',36,'DK','IGL',[72,80,64,88,96,70,96],88],
['rain','Håvard Nygaard',31,'NO','Entry',[87,82,82,72,82,86,82],87],
['frozen','David Čerňanský',24,'SK','Lurker',[90,86,84,78,86,86,80],92],
['EliGE','Jonathan Jablonowski',28,'US','Rifler',[86,82,82,76,84,84,80],87],
['broky','Helvijs Saukants',24,'LV','AWP',[91,88,82,74,86,84,80],92]]},
{name:'G2 Esports',tag:'G2',color:'#c9c9c9',tier:1,region:'Europe',roster:[
['Snax','Janusz Pogorzelski',32,'PL','IGL',[82,80,74,84,88,76,88],86],
['huNter-','Nemanja Kovač',29,'BA','Rifler',[88,86,82,80,86,84,82],89],
['malbsMd','Mario Samayoa',23,'GT','Entry',[87,82,80,72,82,88,78],89],
['HeavyGod','Denis Grigorev',20,'RU','Support',[84,82,76,86,84,80,82],88],
['hyped','Kirill Panyukov',22,'RU','AWP',[85,84,78,72,82,82,78],89]]},
{name:'Team Falcons',tag:'FLCN',color:'#00b489',tier:1,region:'Asia',roster:[
['NiKo','Nikola Kovač',29,'BA','Rifler',[94,88,86,78,88,86,80],94],
['m0NESY','Ilya Osipov',21,'RU','AWP',[96,90,88,72,86,88,78],98],
['kyxsan','Damjan Stoilkovski',23,'MK','IGL',[76,80,70,84,86,78,88],85],
['TeSeS','René Madsen',25,'DK','Support',[86,84,80,84,84,82,84],87],
['kyousuke','Fatih Dayik',21,'TR','Entry',[86,80,80,70,80,88,76],89]]},
{name:'The MongolZ',tag:'MGZ',color:'#f9a825',tier:1,region:'Asia',roster:[
['bLitz','Garidmagnai Byambasuren',24,'MN','IGL',[80,82,74,84,89,80,88],88],
['Techno','Bilguun Mönkhbat',21,'MN','Support',[85,82,78,84,82,84,80],87],
['Senzu','Sumiya Bat-Erdene',21,'MN','Entry',[87,80,80,70,80,90,78],89],
['mzinho','Batbayar Mönkhbold',19,'MN','Rifler',[89,84,82,74,84,88,78],93],
['910','Usukhbayar Banzragch',23,'MN','AWP',[90,86,80,74,84,86,80],92]]},
{name:'Astralis',tag:'AST',color:'#e30613',tier:1,region:'Europe',roster:[
['device','Nicolai Reedtz',30,'DK','AWP',[91,88,84,76,88,84,84],90],
['Staehr','Victor Staehr',21,'DK','Entry',[86,80,80,72,80,86,78],89],
['jabbi','Martin Nygaard',23,'DK','Support',[83,84,78,86,84,80,84],86],
['stavn','Martin Lund',24,'DK','Lurker',[89,88,84,80,86,84,82],91],
['HooXi','Rasmus Nielsen',30,'DK','IGL',[66,78,60,86,87,68,93],80]]},
{name:'Team Liquid',tag:'LIQ',color:'#0000a0',tier:1,region:'Americas',roster:[
['Twistzz','Russel Van Dulken',26,'CA','Rifler',[89,86,84,78,86,86,84],90],
['NAF','Keith Markovic',28,'CA','Lurker',[88,88,82,80,86,84,82],89],
['siuhy','Kamil Szkaradek',24,'PL','IGL',[74,80,68,86,90,76,92],88],
['ultimate','Josiah Ross',23,'US','AWP',[85,84,78,72,82,82,78],88],
['jks','Justin Savage',30,'AU','Support',[83,84,78,84,84,80,84],84]]},
/* ---- TIER 2 ---- */
{name:'FURIA Esports',tag:'FUR',color:'#111111',tier:2,region:'Americas',roster:[
['yuurih','Yuri Santos',26,'BR','Rifler',[88,84,82,78,84,84,80],88],
['KSCERATO','Kaike Cerato',26,'BR','Lurker',[90,88,84,78,86,84,82],90],
['YEKINDAR','Mareks Gaļinskis',26,'LV','Entry',[87,80,82,72,82,90,80],88],
['molodoy','Danil Golubenko',21,'KZ','AWP',[86,84,78,72,82,84,78],90],
['skullz','Felipe Medeiros',24,'BR','Support',[84,82,78,84,82,80,82],84]]},
{name:'Virtus.pro',tag:'VP',color:'#f26522',tier:2,region:'CIS',roster:[
['electroNic','Denis Sharipov',28,'RU','Rifler',[88,86,82,80,86,84,82],89],
['FL1T','Timofey Smirnov',25,'RU','Lurker',[86,84,80,78,84,84,80],87],
['fame','Kirill Mikhailov',23,'RU','AWP',[86,84,78,72,82,82,78],88],
['ICY','Aleksandr Yakovlev',24,'RU','Entry',[84,80,80,72,80,84,78],85],
['Perfecto','Ilya Zalutskiy',26,'RU','Support',[82,84,76,88,84,78,86],84]]},
{name:'Aurora Gaming',tag:'AUR',color:'#7b2ff7',tier:2,region:'Europe',roster:[
['MAJ3R','Engin Küpeli',34,'TR','IGL',[72,78,64,86,85,70,90],80],
['XANTARES','Can Dörtkardeş',30,'TR','Entry',[89,80,82,68,80,88,78],86],
['woxic','Özgür Eker',27,'TR','AWP',[87,84,80,70,82,84,78],86],
['Wicadia','Ali Haydar Yalçın',21,'TR','Rifler',[86,80,80,72,80,86,76],88],
['jottAAA','Ali Osman Yalçın',26,'TR','Support',[82,82,76,84,82,78,84],82]]},
{name:'paiN Gaming',tag:'PAIN',color:'#e6007e',tier:2,region:'Americas',roster:[
['biguzera','Rodrigo Bittencourt',26,'BR','IGL',[80,80,74,84,85,78,86],83],
['nqz','Lucas Soares',21,'BR','AWP',[87,84,80,72,82,84,78],90],
['snow','Gabriel Pereira',19,'BR','Entry',[85,78,80,68,78,88,74],88],
['lux','Bruno Menegatti',21,'BR','Rifler',[83,80,78,74,80,82,78],85],
['kauez','Kauê Oliveira',23,'BR','Support',[81,80,76,84,80,78,82],82]]},
{name:'MIBR',tag:'MIBR',color:'#ffc400',tier:2,region:'Americas',roster:[
['brnz4n','Bruno Andrade',22,'BR','Rifler',[84,80,80,76,80,84,78],86],
['insani','Bruno Marques',22,'BR','Entry',[84,78,80,70,78,86,76],86],
['exit','Bruno Bilhalva',25,'AR','Lurker',[82,82,78,76,80,80,78],82],
['drop','Gabriel Oliveira',23,'BR','Support',[80,80,76,84,80,78,80],80],
['chelo','Marcelo Cespedes',25,'BR','IGL',[78,80,72,82,84,76,86],80]]},
{name:'GamerLegion',tag:'GL',color:'#00c2a8',tier:2,region:'Europe',roster:[
['REZ','Fredrik Sterner',26,'SE','Rifler',[86,84,80,78,84,82,80],85],
['ztr','Daniel Nilsson',20,'SE','IGL',[76,78,70,84,84,76,86],83],
['Tauson','Rasmus Tauson',21,'DK','AWP',[82,82,78,72,80,80,78],84],
['sl3nd','Anton Heinz',21,'DE','Entry',[81,78,78,70,78,84,76],83],
['lattykk','Vladyslav Stepanov',20,'UA','Support',[80,80,74,82,80,78,80],82]]},
{name:'3DMAX',tag:'3DM',color:'#00a3ff',tier:2,region:'Europe',roster:[
['Djoko','Nabil Benrlitom',27,'FR','IGL',[78,80,72,84,85,76,86],82],
['Lucky','Lucas Chastang',24,'FR','AWP',[84,82,78,72,80,80,78],84],
['Ex3rcice','Matthieu Valdenaire',24,'FR','Entry',[83,80,78,70,78,84,76],83],
['Graviti','Yanis Chaoui',21,'MA','Rifler',[82,80,78,74,80,82,78],84],
['Maka','Kevin Bourgeois',26,'FR','Support',[79,80,74,84,80,76,82],79]]},
{name:'Complexity',tag:'COL',color:'#0057b8',tier:2,region:'Americas',roster:[
['JT','Johnny Theodosiou',26,'ZA','IGL',[79,80,72,84,84,76,86],81],
['hallzerk','Håkon Fjærli',25,'NO','AWP',[85,82,78,72,80,80,78],84],
['Grim','Michael Wince',23,'US','Lurker',[83,82,78,76,80,80,78],83],
['floppy','Ricky Kemery',25,'US','Rifler',[82,80,78,76,80,80,78],82],
['cxzi','Christian Figueroa',22,'US','Support',[81,78,76,82,78,78,80],81]]},
/* ---- TIER 3 ---- */
{name:'ENCE',tag:'ENCE',color:'#4cc2ff',tier:3,region:'Europe',roster:[
['podi','Paavo Heiskanen',22,'FI','AWP',[82,80,76,72,78,80,78],83],
['sdy','Vladyslav Svistov',27,'UA','Rifler',[83,82,78,76,80,80,78],82],
['Neityu','Neityu Berdnikov',20,'EE','Entry',[81,78,78,70,78,84,74],83],
['myltsi','Ville Vilkman',20,'FI','Lurker',[79,78,76,76,78,78,78],81],
['Juissi','Jussi Ahonen',23,'FI','IGL',[74,76,68,82,80,74,84],78]]},
{name:'Ninjas in Pyjamas',tag:'NIP',color:'#ffdb00',tier:3,region:'Europe',roster:[
['r1nkle','Rinat Zhaksybaev',20,'KZ','AWP',[82,80,76,70,78,80,76],84],
['Plopski','Jonas Andersson',26,'SE','Rifler',[80,80,78,76,80,78,78],79],
['maxster','Max Angervik',22,'SE','Entry',[79,76,78,70,76,82,74],80],
['Snappi','Marco Pfeiffer',34,'DK','IGL',[68,76,60,84,84,66,90],76],
['MisteM','Milan Šimeček',22,'CZ','Support',[78,78,74,80,78,76,78],79]]},
{name:'BIG',tag:'BIG',color:'#f05a28',tier:3,region:'Europe',roster:[
['tabseN','Johannes Wodarz',31,'DE','IGL',[80,80,74,82,85,76,86],80],
['JDC','Josef Dvořák',23,'CZ','AWP',[80,80,76,72,78,78,78],81],
['hyphen','Nils Adkins',20,'DE','Entry',[79,76,76,70,76,84,74],82],
['Krimbo','Karim Moussa',23,'DE','Support',[78,78,74,82,78,76,80],79],
['kyuubii','Ali Ünlü',22,'DE','Lurker',[78,78,74,76,78,78,76],79]]},
{name:'SAW',tag:'SAW',color:'#00c853',tier:3,region:'Europe',roster:[
['story','Christopher Fernandes',24,'PT','Rifler',[81,80,78,76,80,80,78],81],
['roman','Tiago Romão',23,'PT','AWP',[80,80,76,72,78,78,76],80],
['aragorn','Ricardo Almeida',25,'PT','Entry',[78,76,76,70,76,80,74],78],
['dewbbble','Diogo Silva',24,'PT','Support',[77,78,74,80,78,74,78],78],
['MUTiRiS','Frederico Ferreira',33,'PT','IGL',[70,74,62,82,80,68,86],73]]},
{name:'FlyQuest',tag:'FLY',color:'#7f00ff',tier:3,region:'Oceania',roster:[
['Vexite','Declan Portelli',21,'AU','Rifler',[83,80,78,74,80,82,78],84],
['Liazz','Jay Tregillgas',26,'AU','Lurker',[82,82,78,76,80,80,78],81],
['INS','Joshua Potter',25,'AU','IGL',[76,78,70,82,82,74,84],78],
['aliStair','Alistair Johnston',26,'AU','AWP',[81,80,76,72,78,78,76],79],
['netik','Nikita Novak',20,'NZ','Entry',[79,76,76,70,76,82,74],80]]},
{name:'Wildcard',tag:'WC',color:'#ff3860',tier:3,region:'Americas',roster:[
['JBa','Jackson Barlow',22,'US','Rifler',[79,78,76,74,78,78,76],80],
['susp','Nicholas Susman',23,'US','AWP',[80,80,76,72,78,78,76],80],
['Sonic','Dominick Hewitt',25,'US','Support',[77,78,74,80,78,74,78],77],
['Phzy','Ronald Fisher',21,'US','Entry',[78,76,76,68,74,82,72],79],
['jeorgeeee','George Kritikos',23,'US','IGL',[74,76,68,80,80,72,84],76]]},
{name:'Heroic',tag:'HER',color:'#19a0c8',tier:3,region:'Europe',roster:[
['yxngstxr','Lasse Maaløe',20,'DK','AWP',[81,78,76,70,78,80,74],84],
['Nodios','Niels Andersen',22,'DK','Rifler',[79,78,76,74,78,78,76],82],
['kraghen','Simon Kragskov',21,'DK','Entry',[80,76,76,68,76,82,74],83],
['tN1R','Tanner Ruiz',23,'US','Support',[77,78,74,80,78,76,78],80],
['Diony','Dionisij Jankuloski',24,'MK','IGL',[73,76,66,82,80,72,84],78]]},
{name:'M80',tag:'M80',color:'#e4002b',tier:3,region:'Americas',roster:[
['slaxz-','Felix Jensen',22,'DK','Rifler',[80,78,76,74,78,78,76],83],
['reck','Marcus Reck',21,'US','Entry',[79,76,76,68,76,82,74],83],
['s1n','Sinan Yilmaz',22,'DE','AWP',[80,78,74,70,76,78,74],82],
['oSwald','Oswald Carter',23,'US','Support',[77,78,74,80,78,74,78],79],
['Swisher','Matthew Schmid',25,'US','IGL',[75,76,68,80,80,72,84],78]]},
{name:'Legacy',tag:'LEG',color:'#00b7eb',tier:3,region:'Americas',roster:[
['dumau','Bruno Bassoli',23,'BR','Rifler',[81,78,76,74,78,78,76],84],
['n1ssim','Lucas Vilela',21,'BR','AWP',[80,78,74,70,76,78,74],84],
['latto','Eduardo Ferreira',22,'BR','Entry',[79,76,76,68,76,82,74],82],
['zevy','Kevin Souza',20,'BR','Support',[77,76,74,80,76,76,78],82],
['saadzin','Pedro Saad',24,'BR','IGL',[74,76,68,80,80,72,84],78]]},
{name:'Lynn Vision',tag:'LV',color:'#ffd400',tier:3,region:'Asia',roster:[
['Starry','Zhu Eldong',22,'CN','Rifler',[81,78,76,74,78,78,76],84],
['EmiliaQAQ','Wang Qi',21,'CN','AWP',[80,78,74,70,76,78,74],85],
['z4kr','Zhao Kaiqiang',23,'CN','Entry',[79,76,76,68,76,82,74],82],
['Westmelon','Liu Yao',22,'CN','Support',[77,76,74,80,76,76,78],81],
['Kaze','Ryan Chan',31,'MY','IGL',[76,78,70,80,80,72,86],76]]},
];
/* free agents: [nick, real, age, nat, role, attrs, pot] */
const FREE_AGENTS=[
['k0nfig','Kristian Wienecke',30,'DK','Entry',[85,78,82,68,78,84,76],84],
['blameF','Benjamin Bremer',28,'DK','Lurker',[87,86,82,78,86,82,82],87],
['cadiaN','Casper Møller',30,'DK','IGL',[74,78,68,84,86,72,90],80],
['arT','Andrei Piovezan',28,'BR','IGL',[76,76,72,80,84,82,86],80],
['oSee','Josh Ohm',26,'US','AWP',[86,84,80,72,80,80,78],85],
['nertz','Guy Iluz',23,'IL','Entry',[86,80,80,70,80,88,78],88],
['dycha','Paweł Michałowicz',28,'PL','Rifler',[83,82,78,78,80,80,80],82],
['maden','Liam de Montfalcon',28,'GB','Support',[80,80,76,84,80,78,82],80],
['degster','Abdul Gasanov',24,'RU','AWP',[86,84,78,70,80,82,76],87],
['smooya','Owen Butterfield',26,'GB','AWP',[84,80,78,68,76,80,74],82],
['nicoodoz','Nico Tamjidi',24,'DK','AWP',[83,80,78,70,78,80,76],83],
['sjuush','Rasmus Beck',25,'DK','Support',[80,80,76,84,80,78,82],81],
['ALEX','Alex McMeekin',27,'GB','IGL',[74,78,68,82,84,72,86],79],
['mir','Abay Khassenov',22,'KZ','Rifler',[84,80,78,74,80,82,76],85],
['buster','Evgeniy Tuz',25,'RU','Support',[80,80,74,84,80,76,80],79],
['Krad','Kristjan Jakobson',22,'EE','Lurker',[81,80,76,76,78,80,78],82],
['jkaem','Jørgen Johansen',31,'NO','Rifler',[82,80,76,76,78,80,78],79],
['Twistie','Timo Lehtonen',19,'FI','AWP',[80,78,74,68,76,80,72],87],
['volt','Viktor Olsen',19,'DK','Entry',[80,76,76,66,74,84,70],86],
['rezn0v','Renato Silva',20,'PT','Rifler',[78,76,74,72,76,80,74],84],
['draken','Pontus Söderström',26,'SE','AWP',[82,78,76,68,74,78,72],79],
['shz','Arthur Souza',19,'BR','Rifler',[79,76,74,70,76,82,72],86],
['krystal','Jesper Petersen',32,'DK','IGL',[68,74,58,82,80,66,86],73],
['weber','Jack Webb',30,'GB','Support',[76,76,72,82,78,72,80],75],
['zorte','Nikita Skvortsov',24,'RU','Lurker',[81,80,76,76,78,78,78],80],
['Keiko','Keiko Nakamura',20,'JP','Entry',[79,76,76,68,76,84,72],85],
['storm','Lee Ji-hoon',21,'KR','AWP',[81,78,74,70,78,80,74],84],
['ace','Chen Wang',22,'CN','Rifler',[80,78,76,72,78,80,76],83],
];
/* staff pool: [nick, name, role, skill, salary(monthly)] */
const STAFF_POOL=[
['B1ad3','Andrii Horodenskyi','Coach',94,42000],
['zonic','Danny Sørensen','Coach',92,38000],
['TaZ','Wiktor Wojtas','Coach',85,22000],
['gob b','Johannes Wodarz Sr.','Coach',83,19000],
['RobbaN','Robert Dahlström','Coach',88,28000],
['Rejin','Peter Boersma','Coach',81,16000],
['kassad','Milos Nedeljkovic','Analyst',86,15000],
['MithR','Torbjørn Nyheim','Analyst',84,13000],
['Xist','Jacob Winneche','Analyst',80,10000],
['DataKing','Petar Velikov','Analyst',78,8500],
['scrawny','Harman Sapra','Media Manager',84,9000],
['NoTZki','Paul Reboux','Media Manager',80,7000],
['Machine','James Bardolph','Media Manager',88,12000],
['Sadokist','Matthew Trivett','Media Manager',82,8000],
['Dr.K','Katarina Molnarova','Psychologist',87,11000],
['MindSmith','Aaron Delgado','Psychologist',82,8500],
['ZenPro','Yuki Tanaka','Psychologist',79,6500],
['IronPath','Marek Novak','Physio',86,8000],
['PeakForm','Elena Vasquez','Physio',81,6000],
['RecoveryLab','Jonas Lindqvist','Physio',77,4800],
['LedgerLord','Sarah Chen','Finance Director',89,14000],
['MoneyLine','David Okafor','Finance Director',83,9500],
['DealMaker','Ana Rodrigues','Finance Director',79,7000],
['ScoutMaster','Ivan Petrov','Analyst',75,7000],
];
/* 2026 tournament calendar. day = start day offset from Jan 5 2026 */
const CAL_2026=[
{name:'BLAST Bounty Hunt', day:15, tier:1, days:6, prize:250000, pts:750, slots:16},
{name:'IEM Katowice', day:26, tier:1, days:9, prize:450000, pts:1000, slots:16, gs:true},
{name:'BLAST Open Lisbon', day:63, tier:1, days:8, prize:300000, pts:900, slots:16, gs:true},
{name:'ESL Pro League S23', day:84, tier:1, days:9, prize:350000, pts:850, slots:20},
{name:'IEM Melbourne', day:105,tier:1, days:7, prize:250000, pts:800, slots:14},
{name:'BLAST Rivals Spring', day:119,tier:2, days:6, prize:120000, pts:420, slots:12},
{name:'IEM Dallas', day:140,tier:1, days:7, prize:250000, pts:800, slots:14, gs:true},
{name:'Major Budapest', day:168,tier:1, days:10,prize:800000, pts:1400, slots:24, gs:true, major:true},
{name:'Esports World Cup', day:203,tier:1, days:8, prize:700000, pts:1100, slots:16, gs:true},
{name:'IEM Cologne', day:224,tier:1, days:9, prize:400000, pts:1000, slots:16, gs:true},
{name:'BLAST Fall Open', day:252,tier:2, days:6, prize:150000, pts:450, slots:12},
{name:'Thunderpick World Finals', day:273,tier:2, days:6, prize:200000, pts:500, slots:10},
{name:'IEM Chengdu', day:294,tier:1, days:7, prize:250000, pts:800, slots:14},
{name:'CCT Global Finals', day:315,tier:3, days:5, prize:40000, pts:180, slots:10},
{name:'Elisa Masters Espoo', day:322,tier:3, days:5, prize:50000, pts:200, slots:10},
{name:'BLAST World Final', day:336,tier:1, days:6, prize:500000, pts:1050, slots:8, gs:true},
];
const GS_WINDOW_DAYS=365, GS_REQUIRED=4, GS_BONUS=1000000;
const SPONSOR_BRANDS=['SteelSeries','HyperX','Red Bull','Monster Energy','Intel','AMD Ryzen','Logitech G','Razer','Secretlab','ZOWIE','Corsair','Caseking'];
const SPONSOR_TYPES=['Main Partner','Peripheral Partner','Energy Partner','Apparel Partner','Hardware Partner'];
const FIRST_NAMES=['Aleksi','Nikola','Danil','Mathias','Jonas','Lukas','Mateusz','Emil','Oscar','Felix','Marcus','Adrian','Pavel','Georgi','Marco','Diego','Lucas','Ryan','Tyler','Brandon','Kenji','Wei','Minh','Artem','Dmytro','Tomas','Erik','Nils','João','Pedro','Ahmet','Burak','Yusuf','Temuulen','Batbayar','Sanzhar','Daniyar','Jack','Harry','Oliver'];
const LAST_NAMES=['Virtanen','Kovač','Ivanov','Nielsen','Berg','Weber','Nowak','Jensen','Lindberg','Holm','Sørensen','Silva','Santos','Rossi','Ferrari','Müller','Schmidt','Dupont','Martín','Popov','Yilmaz','Demir','Kaya','Bat-Erdene','Ganzorig','Serik','Abenov','Wilson','Taylor','Clarke','Nguyen','Chen','Tanaka','Kim','Novák','Horvat','Vasquez','Moreno','Costa','Almeida'];
const NICK_SYL1=['nex','zen','dra','kai','vol','sha','ry','tox','fro','bli','sta','mor','ven','qui','zer','py','glit','cra','dyn','flu'];
const NICK_SYL2=['ko','zar','gon','x','zy','ker','no','fire','bit','wave','shot','lock','storm','blade','byte','wolf','fox','hawk','ice','one'];
const NEWS_FLAVOR={
win:['dominated','closed out convincingly','clutched through','outclassed'],
loss:['fell short against','were dismantled by','lost a heartbreaker to','crumbled versus'],
};
function makePlayerFromTuple(t,teamId,G){
const [nick,real,age,nat,role,attrs,pot]=t;
return {
id:U.uid(), nick, name:real, age, nat, role,
attrs:{aim:attrs[0],pos:attrs[1],clutch:attrs[2],util:attrs[3],iq:attrs[4],move:attrs[5],comm:attrs[6]},
potential:pot,
form:U.rnd(4.5,7), morale:U.rnd(55,85), motivation:U.rnd(55,85), fatigue:U.rnd(10,35),
injuryDays:0, teamId, contractUntil:G.day+U.ri(120,900),
salary:0, value:0, listedPrice:null, loanTo:null,
personality:{prof:U.ri(30,95), temper:U.ri(20,90), ambition:U.ri(30,95)},
stats:{maps:0,kills:0,deaths:0,adr:0,ratingSum:0,mvp:0,aces:0},
seasonStats:{maps:0,kills:0,deaths:0,adr:0,ratingSum:0,mvp:0,aces:0},
training:{focus:'Balanced',intensity:1}, talkCooldown:0, promise:null,
};
}
+1030
View File
File diff suppressed because it is too large Load Diff
+708
View File
@@ -0,0 +1,708 @@
/* ============================================================
LIVE — real-time tactical micro-simulation (top-down CS)
A* pathfinding on real geometry, raycast bullets with spread/
falloff/armor/headshots, utility that matters (smoke blocks
LOS, flash blinds, HE/molly damage), plant/defuse/timer.
The round outcome comes OUT of this fight and is fed back
into the career engine by SimSession.finishLiveRound().
============================================================ */
'use strict';
const LIVE=(()=>{
function VZ(){return (typeof window!=='undefined')?window.VIZ:null;}
/* ---------------- maps ---------------- */
const MAPS={
'Dust II':{
pal:{floor:'#182234',wall:'#2c3c5a',crate:'#3b4f73'},
/* lanes separated by real walls: long | mid | tunnels,
joined only at doors / catwalk-stairs / site mouths */
floors:[[12,66,14,10], // T spawn
[26,60,32,8], // outside long (low)
[58,58,4,6], // doors
[62,58,36,8], // along long bottom
[96,16,10,42], // long A (vertical)
[96,6,26,20], // A site
[56,30,40,6], // catwalk (mid->short)
[88,25,8,5], // short stairs
[46,36,9,30], // mid
[40,62,8,6], // tspawn->mid connector
[55,18,41,7], // top-mid (CT side)
[20,46,18,8], // lower tunnel
[16,22,10,24], // upper tunnel
[24,54,8,6], // tspawn->tunnels
[6,6,28,20], // B site
[30,6,72,8], // CT rotate corridor
[110,6,14,14]], // CT spawn
crates:[[101,10],[106,17],[113,12],[119,21],[13,11],[21,16],[27,9],
[51,45],[86,32],[100,44],[120,32]],
tSpawn:[18,71],ctSpawn:[117,11],
sites:{A:[108,15],B:[18,15]},
chokes:{A:[[99,26]],B:[[20,27]]},
holds:{A:[[104,10,'r'],[114,21,'d'],[98,11,'l'],[103,23,'dr']],
B:[[12,9,'l'],[24,19,'d'],[8,20,'dl'],[30,10,'ld']],
mid:[[60,22,'d'],[49,40,'u']]},
flashes:{A:[104,18],B:[17,16]},
},
'Mirage':{
pal:{floor:'#1b2030',wall:'#37304a',crate:'#4a4266'},
floors:[[10,64,16,12],[28,58,30,12],[58,54,10,10],[68,56,34,10],
[100,16,14,42],[96,6,26,20],[52,26,46,8],[44,30,12,36],
[58,16,40,10],[16,42,20,10],[14,18,12,26],[6,6,28,20],
[32,6,68,8],[42,62,10,6],[110,6,14,14]],
crates:[[102,11],[107,17],[112,13],[99,21],[13,11],[21,15],[27,10],
[60,30],[47,50],[104,42],[86,60]],
tSpawn:[17,71],ctSpawn:[117,11],
sites:{A:[109,15],B:[19,15]},
chokes:{A:[[99,27]],B:[[20,27]]},
holds:{A:[[107,10,'r'],[114,21,'d'],[97,13,'l'],[102,23,'dr']],
B:[[11,9,'l'],[25,17,'d'],[7,19,'dl'],[31,11,'ld']],
mid:[[63,21,'d'],[49,33,'u']]},
flashes:{A:[104,17],B:[16,15]},
},
'Inferno':{
pal:{floor:'#20191b',wall:'#4a3038',crate:'#6b4630'},
floors:[[10,62,16,12],[28,58,26,10],[54,52,14,10],[68,50,16,10],
[84,48,20,12],[96,44,24,16],[88,10,14,36],[90,6,28,18],
[46,30,12,26],[58,24,34,8],[16,40,14,22],[14,16,12,26],
[6,6,26,20],[32,6,60,8],[110,24,12,22],[40,60,10,6]],
crates:[[95,11],[101,17],[107,12],[93,20],[12,11],[20,15],[26,10],
[52,33],[62,27],[100,51],[116,52]],
tSpawn:[17,69],ctSpawn:[116,11],
sites:{A:[104,14],B:[106,53]},
chokes:{A:[[92,23]],B:[[92,53]]},
holds:{A:[[101,9,'r'],[111,20,'d'],[92,12,'l'],[98,20,'dr']],
B:[[112,47,'d'],[99,57,'l'],[114,57,'dl'],[96,46,'lu']],
mid:[[62,27,'d'],[50,34,'u']]},
flashes:{A:[98,16],B:[100,55]},
},
'Nuke':{
pal:{floor:'#161e2c',wall:'#33415c',crate:'#465a80'},
floors:[[12,34,18,12],[30,30,18,20],[48,26,44,28],[92,30,26,20],
[56,10,24,16],[56,54,24,16],[100,10,20,16],[100,54,20,16],
[30,12,26,10],[30,58,26,10],[12,12,18,14],[12,54,18,14]],
crates:[[62,14],[71,14],[62,60],[71,60],[52,33],[84,45],[104,16],
[110,61],[36,35]],
tSpawn:[20,40],ctSpawn:[112,40],
sites:{A:[110,17],B:[110,62]},
chokes:{A:[[96,18]],B:[[96,62]]},
holds:{A:[[106,12,'r'],[116,22,'d'],[98,14,'l']],
B:[[106,58,'r'],[116,52,'u'],[98,65,'l']],
mid:[[88,38,'l'],[52,40,'r']]},
flashes:{A:[106,18],B:[106,62]},
},
'Ancient':{
pal:{floor:'#141d22',wall:'#2c4a44',crate:'#3d6459'},
floors:[[12,36,16,10],[28,30,18,22],[46,26,40,28],[86,30,24,20],
[58,8,22,18],[58,54,22,18],[98,8,22,18],[98,54,22,18],
[28,10,28,10],[28,60,28,10]],
crates:[[64,12],[73,12],[64,60],[73,60],[50,31],[82,45],[102,12],
[108,62],[34,35]],
tSpawn:[19,41],ctSpawn:[111,41],
sites:{A:[109,16],B:[109,62]},
chokes:{A:[[94,17]],B:[[94,62]]},
holds:{A:[[105,11,'r'],[115,21,'d'],[97,13,'l']],
B:[[105,58,'r'],[115,51,'u'],[97,66,'l']],
mid:[[82,39,'l'],[50,40,'r']]},
flashes:{A:[104,17],B:[104,61]},
},
};
MAPS['Anubis']=JSON.parse(JSON.stringify(MAPS['Mirage']));
MAPS['Train']=JSON.parse(JSON.stringify(MAPS['Dust II']));
MAPS['Overpass']=JSON.parse(JSON.stringify(MAPS['Ancient']));
MAPS['Vertigo']=JSON.parse(JSON.stringify(MAPS['Nuke']));
/* ---------------- grid / nav ---------------- */
const GW=130,GH=80;
function buildGrid(L){
const g=new Uint8Array(GW*GH);
L.floors.forEach(([x,y,w,h])=>{
for(let yy=Math.max(0,y|0);yy<Math.min(GH,Math.ceil(y+h));yy++)
for(let xx=Math.max(0,x|0);xx<Math.min(GW,Math.ceil(x+w));xx++)g[yy*GW+xx]=1;
});
(L.crates||[]).forEach(([cx,cy])=>{
for(let yy=(cy-2)|0;yy<=((cy+2)|0);yy++)
for(let xx=(cx-2)|0;xx<=((cx+2)|0);xx++)
if(xx>=0&&yy>=0&&xx<GW&&yy<GH)g[yy*GW+xx]=0;
});
ensureConnected(L,g);
return g;
}
function walkable(g,x,y){x|=0;y|=0;return x>=0&&y>=0&&x<GW&&y<GH&&g[y*GW+x]===1;}
function snap(g,x,y){
if(walkable(g,x,y))return{x:x+0.5,y:y+0.5};
for(let r=1;r<=7;r++){
for(let dy=-r;dy<=r;dy++)for(let dx=-r;dx<=r;dx++){
if(Math.max(Math.abs(dx),Math.abs(dy))!==r)continue;
const nx=(x|0)+dx,ny=(y|0)+dy;
if(walkable(g,nx,ny))return{x:nx+0.5,y:ny+0.5};
}
}
return{x:x+0.5,y:y+0.5};
}
function astar(g,sx,sy,tx,ty){
sx|=0;sy|=0;tx|=0;ty|=0;
if(!walkable(g,sx,sy)||!walkable(g,tx,ty))return null;
if(sx===tx&&sy===ty)return[{x:tx+0.5,y:ty+0.5}];
const W=GW;
const open=[{i:sy*W+sx,f:0,g:0}];
const gs=new Float32Array(W*GH).fill(Infinity);
const came=new Int32Array(W*GH).fill(-1);
const closed=new Uint8Array(W*GH);
gs[sy*W+sx]=0;
const H=i=>Math.abs(i%W-tx)+Math.abs(((i/W)|0)-ty);
let guard=0;
while(open.length&&guard++<9000){
let bi=0;
for(let i=1;i<open.length;i++)if(open[i].f<open[bi].f)bi=i;
const cur=open.splice(bi,1)[0],ci=cur.i;
if(closed[ci])continue;closed[ci]=1;
const cx=ci%W,cy=(ci/W)|0;
if(cx===tx&&cy===ty){
const path=[];let i=ci;
while(i!==-1&&i!==sy*W+sx){path.push({x:i%W+0.5,y:((i/W)|0)+0.5});i=came[i];}
path.push({x:sx+0.5,y:sy+0.5});path.reverse();
// string-pull lite: drop intermediate nodes with clear LOS
const sm=[path[0]];let j=0;
for(let k=2;k<path.length;k++){
if(!losClear(g,path[j].x,path[j].y,path[k].x,path[k].y)){sm.push(path[k-1]);j=k-1;}
}
sm.push(path[path.length-1]);
return sm;
}
for(const[dx,dy]of[[1,0],[-1,0],[0,1],[0,-1]]){
const nx=cx+dx,ny=cy+dy;
if(!walkable(g,nx,ny))continue;
const ni=ny*W+nx,ng=cur.g+1;
if(ng<gs[ni]){gs[ni]=ng;came[ni]=ci;open.push({i:ni,g:ng,f:ng+H(ni)});}
}
}
return null;
}
function losClear(g,x0,y0,x1,y1){
const dx=x1-x0,dy=y1-y0,d=Math.hypot(dx,dy);
if(d<0.01)return true;
return !raycast(g,x0,y0,dx/d,dy/d,d,null).hit;
}
function raycast(g,x0,y0,dx,dy,maxD,smokes){
let x=x0,y=y0;const stp=0.45;
for(let d=0;d<maxD;d+=stp){
x+=dx*stp;y+=dy*stp;
if(!walkable(g,x,y))return{x:x-dx*stp,y:y-dy*stp,hit:true,d};
if(smokes)for(let i=0;i<smokes.length;i++){
const s=smokes[i];
if(s.life>0.12&&s.life<0.9){
const ex=x-s.x,ey=y-s.y;
if(ex*ex+ey*ey<s.r*s.r)return{x,y,hit:false,smoke:true,d};
}
}
}
return{x,y,hit:false,d:maxD};
}
/* guarantee every spawn can reach both sites: carve corridors if needed */
function ensureConnected(L,g){
[L.tSpawn,L.ctSpawn].forEach(sp=>{
if(!walkable(g,sp[0],sp[1])){const s=snap(g,sp[0],sp[1]);sp[0]=s.x;sp[1]=s.y;}
const targets=[L.sites.A,L.sites.B]
.concat(L.chokes.A||[],L.chokes.B||[]);
targets.forEach(tg=>{
if(walkable(g,tg[0],tg[1])&&astar(g,sp[0],sp[1],tg[0],tg[1]))return;
const put=(x,y)=>{
[[0,0],[1,0],[-1,0],[0,1],[0,-1]].forEach(([dx,dy])=>{
const nx=x+dx,ny=y+dy;
if(nx>=1&&ny>=1&&nx<GW-1&&ny<GH-1)g[ny*GW+nx]=1;
});
};
let x=sp[0]|0,y=sp[1]|0;
const tx=tg[0]|0,ty=tg[1]|0;
while(x!==tx){x+=tx>x?1:-1;put(x,y);}
while(y!==ty){y+=ty>y?1:-1;put(x,y);}
tg[0]=tx+0.5;tg[1]=ty+0.5;
L.carved=L.carved||[];
L.carved.push([Math.min(sp[0],tx)-1,Math.min(sp[1],ty)-1,
Math.abs(tx-sp[0])+3,Math.abs(ty-sp[1])+3]);
});
});
}
/* ---------------- weapons ---------------- */
const WEAPONS={
awp:{name:'AWP ⭐',dmg:115,rpm:45,rng:60,spread:0.015,move:0.7,big:true},
rifle:{name:'Rifle',dmg:37,rpm:600,rng:38,spread:0.042,move:0.9},
smg:{name:'SMG',dmg:28,rpm:780,rng:24,spread:0.06,move:1.0},
pistol:{name:'Pistol',dmg:32,rpm:400,rng:20,spread:0.05,move:1.0},
};
/* ---------------- round construction ---------------- */
function makeRound(cfg){
const L=MAPS[cfg.map]||MAPS['Mirage'];
L.w=L.w||130;L.h=L.h||80;
const g=buildGrid(L);
const tKey=cfg.ctSide==='a'?'b':'a';
const mk=(pid,side)=>{
const p=cfg.players[pid],sk=cfg.skills[pid];
const isCT=side===cfg.ctSide;
const sp=isCT?L.ctSpawn:L.tSpawn;
const p0=snap(g,sp[0]+(Math.random()-0.5)*6,sp[1]+(Math.random()-0.5)*6);
return{pid,nick:p.nick,role:p.role,side,isCT,
x:p0.x,y:p0.y,
hp:100,armor:cfg.buys[side]==='full'?100:cfg.buys[side]==='force'?60:0,
dead:false,ang:isCT?-Math.PI/2:Math.PI/2,
spd:(0.82+sk.spd*0.0035),
react:U.clamp(0.46-sk.aim*0.0026-sk.clutch*0.0007,0.13,0.5)*(cfg.timeoutSide===side?0.82:1),
acc:U.clamp(sk.aim/100,0.3,1),
weapon:(cfg.buys[side]==='full')
?(p.role==='AWP'?WEAPONS.awp:WEAPONS.rifle)
:(cfg.buys[side]==='force'?WEAPONS.smg:WEAPONS.pistol),
cool:0,blind:0,target:null,lastSeenT:-9,
path:null,pathI:0,goal:null,fake:false,alertT:-9,
holdNear:null,kills:0,dmg:0,bob:Math.random()*6.28,moving:false,
reactT:null};
};
const agents=[];
cfg.lineups.a.forEach(pid=>agents.push(mk(pid,'a')));
cfg.lineups.b.forEach(pid=>agents.push(mk(pid,'b')));
// CT setup: split holds (2-2-1 default)
const cts=agents.filter(a=>a.isCT);
const split=Math.random()<0.75?[2,2,1]:Math.random()<0.5?[3,1,1]:[1,3,1];
let ci=0;
['A','B','mid'].forEach((setName,si)=>{
const hs=L.holds[setName];
for(let i=0;i<split[si]&&ci<cts.length;i++,ci++){
const a=cts[ci],h=hs[(ci+i)%hs.length];
const hp=snap(g,h[0]+(Math.random()-0.5)*2,h[1]+(Math.random()-0.5)*2);
a.x=hp.x;a.y=hp.y;
a.hold=h;a.task='hold';
const fa=h[2];
a.ang=fa==='r'?0:fa==='l'?Math.PI:fa==='u'?-Math.PI/2:fa==='d'?Math.PI/2:
fa==='dr'?Math.PI/4:fa==='dl'?Math.PI*0.75:fa==='lu'?-Math.PI*0.75:-Math.PI/4;
}
});
// attacker plan
const atkTac=cfg.tactic[tKey];
const site=Math.random()<0.5?'A':'B';
const atks=agents.filter(a=>!a.isCT);
const nFake=atkTac==='fake'?2:0;
atks.forEach((a,i)=>{
a.fake=i<nFake;
const tgt=a.fake?L.sites[site==='A'?'B':'A']:L.sites[site];
const gp=snap(g,tgt[0],tgt[1]);
a.goal={x:gp.x,y:gp.y};
});
const utilPlan=[];
if(atkTac==='fake'){
const o=site==='A'?'B':'A';
L.chokes[o].forEach(([x,y])=>utilPlan.push({t:7,type:'smoke',x,y}));
}else{
L.chokes[site].forEach(([x,y],i)=>utilPlan.push({t:5+i*1.6,type:'smoke',x,y}));
const fl=L.flashes[site];
utilPlan.push({t:8,type:'flash',x:fl[0],y:fl[1]});
}
return {cfg,L,g,agents,site,tKey,t:0,timeLeft:115,
planted:false,plantP:0,bombPos:null,bombT:0,
bombCarrier:atks[Math.floor(Math.random()*atks.length)],
defuseP:0,defuser:null,
smokes:[],fires:[],nades:[],fx:[],corpses:[],feed:[],
callout:null,calloutT:-9,
winner:null,reason:null,killsLog:[],
pain:0,shake:0,ended:false,endT:0,
firstBlood:false,clutchFlag:false,
utilPlan,utilDone:new Set(),lastFlashAt:-9};
}
/* ---------------- utility ---------------- */
function throwNade(st,type,x,y){st.nades.push({type,x,y,fuse:type==='flash'?0.7:type==='he'?1.0:1.1,total:1.1,progress:0,z:0});}
function detonate(st,n){
if(n.type==='smoke'){st.smokes.push({x:n.x,y:n.y,r:12.5,life:0});}
else if(n.type==='flash'){
st.fx.push({type:'flashPop',t0:st.t,dur:0.09});
st.agents.forEach(a=>{
if(a.dead)return;
const d=Math.hypot(a.x-n.x,a.y-n.y);
if(d<16&&!raycast(st.g,n.x,n.y,(a.x-n.x)/d,(a.y-n.y)/d,d,null).hit)
a.blind=Math.max(a.blind,U.clamp(2.5-d*0.1,0.4,2.5));
});
const vz=VZ();if(vz)vz.sFlash();
}else if(n.type==='he'){
st.fx.push({type:'boom',x:n.x,y:n.y,t0:st.t,dur:0.35});
st.shake=Math.min(st.shake+1.7,3);
const vz=VZ();if(vz)vz.sBoom();
st.agents.forEach(a=>{
if(a.dead)return;
const d=Math.hypot(a.x-n.x,a.y-n.y);
if(d<11)a.hp-=Math.max(0,55*(1-d/11));
});
}
}
/* ---------------- helpers ---------------- */
function visAnyEnemy(st,a){
for(const e of st.agents){
if(e.dead||e.isCT===a.isCT)continue;
if(los(st.g,a,e,st.smokes))return true;
}
return false;
}
function los(g,a,b,smokes){
const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy);
if(d<0.01)return true;
const r=raycast(g,a.x,a.y,dx/d,dy/d,d,smokes);
return !r.hit&&!r.smoke;
}
function hotSite(st){
let ht=-9,hx=0,hy=0;
const A=st.L.sites.A,B=st.L.sites.B;
st.agents.forEach(m=>{
if(m.lastSeenT>ht){ht=m.lastSeenT;hx=m.seenX;hy=m.seenY;}
});
if(ht<=0)return null;
// contact older than 25s stops drawing rotations
if(st.t-ht>25)return null;
return Math.hypot(hx-A[0],hy-A[1])<Math.hypot(hx-B[0],hy-B[1])?'A':'B';
}
function holdSideOf(st,a){
const A=st.L.sites.A,B=st.L.sites.B;
return Math.hypot(a.x-A[0],a.y-A[1])<Math.hypot(a.x-B[0],a.y-B[1])?'A':'B';
}
/* ---------------- movement ---------------- */
function moveAlong(st,a,to,dt,noSmooth){
const g=st.g;
let to2=to;
if(a.via){
if(Math.hypot(a.via.x-a.x,a.via.y-a.y)<1.6)a.via=null;
else to2=a.via;
}
const last=a.path&&a.path[a.path.length-1];
const needNew=!a.path||a.pathI>=a.path.length||
Math.hypot(last.x-to2.x,last.y-to2.y)>2.5;
if(needNew){
a.path=astar(g,a.x,a.y,to2.x,to2.y)||[{x:to2.x,y:to2.y}];
a.pathI=0;
}
let tgt=a.path[Math.min(a.pathI,a.path.length-1)];
if(Math.hypot(tgt.x-a.x,tgt.y-a.y)<0.9&&a.pathI<a.path.length-1){a.pathI++;tgt=a.path[a.pathI];}
const dx=tgt.x-a.x,dy=tgt.y-a.y,d=Math.hypot(dx,dy);
if(d>0.12){
const sp=a.spd*3*dt;
const nx=a.x+dx/d*sp,ny=a.y+dy/d*sp;
let moved=false;
if(walkable(g,nx,ny)){a.x=nx;a.y=ny;moved=true;}
else if(walkable(g,nx,a.y)){a.x=nx;moved=true;} // slide X
else if(walkable(g,a.x,ny)){a.y=ny;moved=true;} // slide Y
if(moved){a._stuck=0;a.ang=Math.atan2(dy,dx);a.moving=true;}
else{
a.moving=false;
a._stuck=(a._stuck||0)+1;
if(a._stuck>5){
a._stuck=0;a.path=null;a.via=null;
for(let rr=3;rr<=10;rr+=3){
const s=snap(g,a.x+(Math.random()-0.5)*rr*2,a.y+(Math.random()-0.5)*rr*2);
if(Math.hypot(s.x-a.x,s.y-a.y)>2){a.via={x:s.x,y:s.y};break;}
}
}
}
}else{a.moving=false;a._stuck=0;}
}
/* ---------------- combat ---------------- */
function shoot(st,a,vis){
a.shots=(a.shots||0)+1;
const d=Math.hypot(vis.x-a.x,vis.y-a.y);
const w=a.weapon;
const spread=w.spread*(1+(a.moving?0.85:0))*(1.35-Math.min(d/w.rng,1)*0.6)/(0.45+a.acc*0.8);
const ang=a.ang+(Math.random()-0.5)*spread*5.5;
const maxD=Math.min(w.rng*1.7,d+15);
const hit=raycast(st.g,a.x,a.y,Math.cos(ang),Math.sin(ang),maxD,st.smokes);
let victim=null,hitAt={x:hit.x,y:hit.y};
if(!hit.hit&&!hit.smoke){
let bt=1e9;
for(const e of st.agents){
if(e.dead||e.isCT===a.isCT)continue;
const rx=e.x-a.x,ry=e.y-a.y;
const tt=rx*Math.cos(ang)+ry*Math.sin(ang);
if(tt<0||tt>hit.d)continue;
const px=a.x+Math.cos(ang)*tt,py=a.y+Math.sin(ang)*tt;
const dd=Math.hypot(px-e.x,py-e.y);
if(dd<1.25&&tt<bt){bt=tt;victim=e;hitAt={x:px,y:py};}
}
}
st.fx.push({type:'tracer',x1:a.x+Math.cos(ang)*2,y1:a.y+Math.sin(ang)*2,x2:hitAt.x,y2:hitAt.y,t0:st.t,dur:0.05});
st.fx.push({type:'muzzle',x:a.x+Math.cos(ang)*2.2,y:a.y+Math.sin(ang)*2.2,t0:st.t,dur:0.04,ang});
if(victim){
const fall=U.clamp(1-d/(w.rng*2.4),0.55,1);
let dmg=w.dmg*fall;
const hs=Math.random()<0.15+a.acc*0.12;
if(hs)dmg*=2.4;
if(victim.armor>0){victim.armor=Math.max(0,victim.armor-dmg*0.5);dmg*=0.68;}
dmg=Math.min(dmg,victim.hp);
victim.hp-=dmg;a.dmg+=dmg;
st.fx.push({type:'bloodHit',x:victim.x,y:victim.y,t0:st.t,dur:0.1});
const vz=VZ();if(vz)vz.sHit();
if(victim.hp<=0)kill(st,victim,a,w.name+(hs?' ⌖':''));
}else{
st.fx.push({type:hit.hit?'spark':'puff',x:hitAt.x,y:hitAt.y,t0:st.t,dur:0.13});
}
const vz=VZ();if(vz)vz.sShot(w.big);
}
function kill(st,v,killer,wpn){
if(v.dead)return;
v.dead=true;v.hp=0;
st.corpses.push({x:v.x,y:v.y,ang:v.ang,ct:v.isCT,grow:0});
st.shake=Math.min(st.shake+0.9,2.4);
const vz=VZ();if(vz)vz.sDeath();
const wct=killer?killer.isCT:!v.isCT;
if(killer){
killer.kills++;
st.killsLog.push({k:killer.pid,v:v.pid,wpn:wpn||''});
st.feed.unshift({t0:st.t,killer:killer.nick,kCT:killer.isCT,wpn:wpn||'',victim:v.nick,vCT:v.isCT});
if(st.feed.length>5)st.feed.pop();
}
if(v.side===st.cfg.userSide)st.pain=1;
if(!st.firstBlood){st.firstBlood=true;callout(st,'FIRST BLOOD!','#ff5c5c');}
const aliveCT=st.agents.filter(x=>!x.dead&&x.isCT).length;
const aliveT=st.agents.filter(x=>!x.dead&&!x.isCT).length;
if(aliveCT===1&&aliveT>=2&&!st.clutchFlag&&wct){st.clutchFlag=true;callout(st,'🧊 CLUTCH TIME!','#ffd34d');}
if(aliveT===1&&aliveCT>=2&&!st.clutchFlag&&!wct){st.clutchFlag=true;callout(st,'🧊 CLUTCH TIME!','#ffd34d');}
const tKey=st.cfg.ctSide==='a'?'b':'a';
if(aliveCT===0){
if(st.planted)endRound(st,tKey,'bomb');
else endRound(st,tKey,'elim');
}else if(aliveT===0&&!st.planted){
endRound(st,st.cfg.ctSide,'elim');
}
if(v===st.defuser){st.defuser=null;st.defuseP=0;}
if(v===st.bombCarrier&&!st.planted){
const ts=st.agents.filter(x=>!x.dead&&!x.isCT);
if(ts.length)st.bombCarrier=ts[0];
}
}
function callout(st,txt,color){st.callout={txt,color};st.calloutT=st.t;}
/* ---------------- AI brains ---------------- */
function tBrain(st,a,dt){
// late-round urgency: commit everyone left to the chosen site
if(!st.planted&&!a.fake&&st.timeLeft<78&&!a._rushed){
a._rushed=true;
const tg=st.L.sites[st.site];const gp=snap(st.g,tg[0],tg[1]);
a.goal={x:gp.x,y:gp.y};a.path=null;a.via=null;
}
if(st.planted){
// post-plant: watch entrances near bomb
const entr=st.L.chokes[st.site][0];
a.moving=false;
if(Math.hypot(a.x-st.bombPos.x,a.y-st.bombPos.y)>9)moveAlong(st,a,{x:st.bombPos.x,y:st.bombPos.y},dt);
else a.ang=Math.atan2(entr[1]-a.y,entr[0]-a.x)+(a.bob%1-0.5)*0.5;
return;
}
const siteC=st.L.sites[st.site];
if(a.fake){
const o=st.site==='A'?'B':'A';
const ch=st.L.chokes[o][0];
if(Math.hypot(a.x-ch[0],a.y-ch[1])>6)moveAlong(st,a,{x:ch[0],y:ch[1]},dt);
else if(st.t>16){a.fake=false;const tg=st.L.sites[st.site];a.goal={x:tg[0],y:tg[1]};}
return;
}
const atSite=Math.hypot(a.x-siteC[0],a.y-siteC[1])<9.5;
if(atSite){
let blocker=null,bd=1e9;
st.agents.forEach(e=>{
if(e.dead||!e.isCT)return;
const dd=Math.hypot(e.x-a.x,e.y-a.y);
if(dd<bd&&(dd<8||los(st.g,a,e,st.smokes))){bd=dd;blocker=e;}
});
const desperate=st.timeLeft<30&&bd>4.5;
const enemiesNear=blocker&&!desperate;
if(st.bombCarrier===a&&!enemiesNear){
a.moving=false;
st.plantP+=dt/3.2;
if(Math.floor(st.plantP*8)!==a._lastBeep){a._lastBeep=Math.floor(st.plantP*8);}
if(st.plantP>=1){
st.planted=true;st.bombPos={x:a.x,y:a.y};st.bombT=40;st.plantP=1;
callout(st,'💣 BOMB PLANTED','#ffab4d');
const vz=VZ();if(vz)vz.sPlant();
}
return;
}
if(blocker&&st.timeLeft<70){
// entry fight: collapse onto the defender
moveAlong(st,a,{x:blocker.x,y:blocker.y},dt);
return;
}
// support: crowd around site watching chokes
a.moving=false;
a.ang=Math.atan2(st.L.chokes[st.site][0][1]-a.y,st.L.chokes[st.site][0][0]-a.x)+(a.bob%1-0.5)*0.6;
return;
}
moveAlong(st,a,a.goal,dt);
}
function ctBrain(st,a,dt){
if(st.planted){
if(st.defuser&&st.defuser!==a){}
const d=Math.hypot(a.x-st.bombPos.x,a.y-st.bombPos.y);
if(d>2.3)moveAlong(st,a,st.bombPos,dt);
else{
a.moving=false;
if(visAnyEnemy(st,a))return; // cover first
}
return;
}
const hot=hotSite(st);
const mine=a.holdNear||(a.holdNear=holdSideOf(st,a));
if(hot&&hot!==mine){
moveAlong(st,a,{x:st.L.sites[hot][0],y:st.L.sites[hot][1]},dt);
return;
}
if(a.hold&&st.planted===false&&st.timeLeft<58){
// aggressive CTs collapse toward the hotter choke to break the stalemate
const target=(hot||st.siteGuess||(st.siteGuess=Math.random()<0.5?'A':'B'));
const ch=st.L.chokes[target][0];
if(Math.hypot(a.x-ch[0],a.y-ch[1])>6){moveAlong(st,a,{x:ch[0],y:ch[1]},dt);return;}
}
if(a.hold){
a.moving=false;
const fa=a.hold[2];
const base=fa==='r'?0:fa==='l'?Math.PI:fa==='u'?-Math.PI/2:fa==='d'?Math.PI/2:
fa==='dr'?Math.PI/4:fa==='dl'?Math.PI*0.75:fa==='lu'?-Math.PI*0.75:-Math.PI/4;
a.ang=base+Math.sin(st.t*0.85+a.bob)*0.07;
}
}
/* ---------------- main step ---------------- */
function step(st,dt){
if(st.ended){st.endT+=dt;decayFx(st);return;}
st.timeLeft-=dt;
if(st.timeLeft<=0&&!st.planted){endRound(st,st.cfg.ctSide,'time');decayFx(st);return;}
st.t+=dt;
// scheduled attacker utility
st.utilPlan.forEach(u=>{
const key=''+u.t+'_'+u.type;
if(st.t>=u.t&&!st.utilDone.has(key)){
st.utilDone.add(key);
throwNade(st,u.type,u.x,u.y);
}
});
// nades
st.nades=st.nades.filter(n=>{
n.fuse-=dt;n.progress+=dt/n.total;n.z=Math.sin(Math.min(n.progress,1)*Math.PI)*3;
if(n.fuse<=0){detonate(st,n);return false;}
return true;
});
st.smokes.forEach(s=>s.life+=dt/17);
st.smokes=st.smokes.filter(s=>s.life<1);
st.fires.forEach(f=>{
f.life+=dt/5;
st.agents.forEach(a=>{
if(!a.dead&&Math.hypot(a.x-f.x,a.y-f.y)<f.r){a.hp-=dt*15;
if(a.hp<=0)kill(st,a,null,'🔥 Molly');}
});
});
st.fires=st.fires.filter(f=>f.life<1);
st.agents.forEach(a=>{
if(a.dead)return;
a.cool-=dt;
if(a.blind>0)a.blind-=dt;
if(a.hp<=0){kill(st,a,null,'🔥');return;}
// perception
let vis=null;
if(a.blind<=0){
if(st.t-(a._vt||-9)>=0.12){
let visD=1e9;
for(const e of st.agents){
if(e.dead||e.isCT===a.isCT)continue;
const d=Math.hypot(e.x-a.x,e.y-a.y);
if(d>Math.min(62,a.weapon.rng*1.5+8))continue;
const da=Math.atan2(e.y-a.y,e.x-a.x);
const diff=Math.abs(((da-a.ang)+Math.PI*3)%(Math.PI*2)-Math.PI);
if(d>3.5&&diff>1.2)continue;
if(los(st.g,a,e,st.smokes)&&d<visD){vis=e;visD=d;}
}
a._vis=vis;a._vt=st.t;
}
vis=a._vis&&!a._vis.dead?a._vis:null;
}
if(vis){
a.target=vis;a.seenX=vis.x;a.seenY=vis.y;a.lastSeenT=st.t;
st.agents.forEach(m=>{
if(m!==a&&!m.dead&&m.isCT===a.isCT&&Math.hypot(m.x-a.x,m.y-a.y)<26)m.alertT=st.t;
});
a.reactT=(a.reactT===null||a.reactT===undefined)?a.react*(0.7+Math.random()*0.6):a.reactT-dt;
a.ang=Math.atan2(vis.y-a.y,vis.x-a.x)+(Math.random()-0.5)*0.05;
if(a.reactT<=0&&a.cool<=0){shoot(st,a,vis);a.cool=60/a.weapon.rpm;}
// attackers keep pushing while trading (entry discipline by tactic)
if(!a.isCT&&!st.planted){
const gd=a.goal?Math.hypot(a.goal.x-a.x,a.goal.y-a.y):99;
const push=st.cfg.tactic[st.tKey]==='aggr'?1:0.45;
if(gd>7&&Math.random()<push)moveAlong(st,a,a.goal,dt);
}else a.moving=false;
return;
}
if(a.target&&(st.t-a.lastSeenT>1.3)){a.target=null;a.reactT=null;}
if(a.isCT)ctBrain(st,a,dt);else tBrain(st,a,dt);
});
// defuse
if(st.planted){
st.bombT-=dt;
if(st.bombT<=0){explodeBomb(st);return;}
if(st.defuser){
const du=st.defuser;
if(du.dead||Math.hypot(du.x-st.bombPos.x,du.y-st.bombPos.y)>2.4){st.defuser=null;st.defuseP=0;}
else{
st.defuseP+=dt/9;
if(st.defuseP>=1){endRound(st,st.cfg.ctSide,'defuse');return;}
}
}else{
for(const c of st.agents){
if(c.dead||!c.isCT)continue;
if(Math.hypot(c.x-st.bombPos.x,c.y-st.bombPos.y)<2.5&&!visAnyEnemy(st,c)){
st.defuser=c;c.moving=false;break;
}
}
}
}
// gentle separation
const al=st.agents.filter(a=>!a.dead);
for(let i=0;i<al.length;i++)for(let j=i+1;j<al.length;j++){
const A=al[i],B=al[j];
const dx=B.x-A.x,dy=B.y-A.y,d=Math.hypot(dx,dy);
if(d>0.01&&d<1.4){
const px=dx/d*(1.4-d)*0.5,py=dy/d*(1.4-d)*0.5;
if(walkable(st.g,A.x-px,A.y-py)){A.x-=px;A.y-=py;}
if(walkable(st.g,B.x+px,B.y+py)){B.x+=px;B.y+=py;}
}
}
decayFx(st);
}
function decayFx(st){
st.fx=st.fx.filter(f=>(st.t-f.t0)<f.dur);
st.pain*=0.985;st.shake*=0.9;
}
function explodeBomb(st){
st.fx.push({type:'boom',x:st.bombPos.x,y:st.bombPos.y,t0:st.t,dur:0.6});
st.fx.push({type:'flashPop',t0:st.t,dur:0.22});
const vz=VZ();if(vz)vz.sBoom();
st.agents.forEach(a=>{
if(!a.dead&&Math.hypot(a.x-st.bombPos.x,a.y-st.bombPos.y)<10)a.hp-=200;
});
// mark deaths without recursion issues
st.agents.filter(a=>!a.dead&&a.hp<=0).forEach(a=>kill(st,a,null,'💥 C4'));
endRound(st,st.tKey,'bomb');
}
function endRound(st,winnerKey,reason){
if(st.ended)return;
st.ended=true;st.winner=winnerKey;st.reason=reason;st.endT=0;
const won=winnerKey===st.cfg.userSide;
const vz=VZ();if(vz)vz.sWinSting(won);
}
return {MAPS,buildGrid,astar,raycast,losClear,makeRound,step,WEAPONS,
ensureConnectedForTest:(L,g)=>{if(!L._conn){ensureConnected(L,g);L._conn=true;}}};
})();
if(typeof window!=='undefined')window.LIVE=LIVE;
+77
View File
@@ -0,0 +1,77 @@
'use strict';
/* ================= bootstrap ================= */
const Main={
boot(){
const info=this.saveInfo();
if(info){
$('btnContinue').style.display='';
$('btnContinue').innerHTML=`▶ Continue — ${U.esc(info.tag)} · S${info.season} · ${info.date}`;
}
},
saveInfo(){
const ls=Save.store();if(!ls)return null;
try{
const g=JSON.parse(ls.getItem(SAVE_KEY));
if(!g||g.ver!==1)return null;
const t=g.teams[g.playerTeamId];
return {tag:t?t.tag:'?',season:g.season,date:U.fmtDate(g.day)};
}catch(e){return null;}
},
continueGame(){
if(Save.load()){
UI.show();
UI.toast('Career loaded — welcome back, GM.','good');
}else{
alert('Save data was corrupted. Start a new career.');
Save.wipe();
}
},
toTitle(){
Save.save(true);
$('gameShell').style.display='none';
$('simOverlay').style.display='none';
$('titleScreen').style.display='flex';
$('btnContinue').style.display=Save.exists()?'':'none';
SimSession.s=null;
},
exportSave(){
if(!G){alert('No career running.');return;}
const blob=new Blob([Game.serialize()],{type:'application/json'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download=`csm26_${(G.teams[G.playerTeamId]?.tag||'save')}_S${G.season}_d${G.day}.json`;
a.click();URL.revokeObjectURL(a.href);
UI.toast('Save exported.','good');
},
importSave(){
const inp=document.createElement('input');
inp.type='file';inp.accept='.json,application/json';
inp.onchange=()=>{
const f=inp.files[0];if(!f)return;
const rd=new FileReader();
rd.onload=()=>{
try{
Game.staticLoad(String(rd.result));
Save.save(true);
UI.closeModal();
UI.show();
UI.toast('Save imported ✔','good');
}catch(e){alert('Invalid save file.');}
};
rd.readAsText(f);
};
inp.click();
},
};
window.addEventListener('DOMContentLoaded',()=>Main.boot());
window.addEventListener('visibilitychange',()=>{if(document.visibilityState==='hidden'&&G)Save.save(true);});
/* keyboard: Space/Enter = advance week · Esc = close modal */
window.addEventListener('keydown',e=>{
if(e.key==='Escape'){const m=$('modalBg');if(m){m.remove();return;}}
if(!G)return;
if(e.key!==' '&&e.key!=='Enter')return;
if($('modalBg')||$('simOverlay').style.display==='flex'||$('titleScreen').style.display==='flex')return;
if(['INPUT','SELECT','TEXTAREA','BUTTON'].includes(document.activeElement?.tagName))return;
e.preventDefault();
UI.advance();
});
+603
View File
@@ -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();
},
};
+995
View File
@@ -0,0 +1,995 @@
'use strict';
/* ================= UI layer ================= */
const $=id=>document.getElementById(id);
const UI={
route:'dashboard',
/* ---------- shell ---------- */
show(){
$('titleScreen').style.display='none';
$('newGameScreen').style.display='none';
$('gameShell').style.display='grid';
this.go('dashboard');
},
go(route){
this.route=route;
document.querySelectorAll('.nav-btn').forEach(b=>b.classList.toggle('active',b.dataset.route===route));
const R={dashboard:this.rDash,squad:this.rSquad,training:this.rTraining,transfers:this.rTransfers,
tournaments:this.rTours,matches:this.rMatches,staff:this.rStaff,finance:this.rFinance,
rankings:this.rRankings,inbox:this.rInbox};
(R[route]||this.rDash).call(this);
this.updateTopbar();
const v=$('view');if(v)v.scrollTop=0;
},
updateTopbar(){
if(!G)return;
const me=Game.me();
$('orgBadge').innerHTML=`<span class="dot" style="background:${me.color}"></span><div>${U.esc(me.name)}<small>${me.tag} · #${Game.rankOf(me.id)||'—'} WORLD</small></div>`;
$('tbMoney').textContent=U.fmtM(G.money);
$('tbMoney').className=G.money<0?'bad':'good';
const net=(Game.lastWeeklyMerch||0)-(Game.lastWeeklyWages||0);
$('tbNet').textContent=(net>=0?'+':'')+U.fmtM(net);
$('tbNetIc').textContent=net>=0?'📈':'📉';
$('tbNet').style.color=net>=0?'var(--good)':'var(--bad)';
$('tbFans').textContent=U.fmtNum(G.fans);
$('tbRank').textContent='#'+(Game.rankOf(me.id)||'—');
$('tbBoard').textContent=Math.round(G.board)+'%';
$('tbBoard').style.color=G.board>50?'var(--good)':G.board>25?'var(--warn)':'var(--bad)';
$('tbDate').textContent=U.fmtDate(G.day)+' · S'+G.season;
$('tbNextMatch').textContent=this.nextFixtureInfo()||'no match scheduled';
const unread=G.news.filter(n=>!n.read).length;
const pill=$('inboxCount');pill.style.display=unread?'':'none';pill.textContent=unread;
},
nextFixtureInfo(){
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.a===G.playerTeamId||m.b===G.playerTeamId)&&!m.winner&&m.a&&m.b)
return `${ev.name}: vs ${Game.team(m.a===G.playerTeamId?m.b:m.a).tag} · ${U.fmtDateShort(m.day)}`;
}
const next=G.calendar.find(e=>e.status!=='done'&&e.registered&&e.startDay>G.day);
return next?`next: ${next.name} (${U.fmtDateShort(next.startDay)})`:'';
},
toast(msg,cls=''){
const t=document.createElement('div');t.className='toast '+cls;t.innerHTML=msg;
$('toastRoot').appendChild(t);setTimeout(()=>t.remove(),4200);
},
modal(html,wide){
this.closeModal();
const bg=document.createElement('div');bg.className='modal-bg';bg.id='modalBg';
bg.innerHTML=`<div class="modal" style="${wide?'width:min(980px,96vw)':''}">${html}</div>`;
bg.addEventListener('click',e=>{if(e.target===bg)bg.remove();});
$('modalRoot').appendChild(bg);return bg;
},
closeModal(){const m=$('modalBg');if(m)m.remove();},
/* ---------- advance ---------- */
advance(){
const fx=Game.advanceWeek();
this.updateTopbar();
if(fx)this.openMatchModal(fx);
else{
this.refreshRoute();
this.toast(`📅 ${U.fmtDate(G.day)}`);
}
},
refreshRoute(){this.go(this.route);},
continueAfterMatch(){
this.closeModal();
$('simOverlay').style.display='none';
const fx=Game.remainingAdvance()>0?Game.resumeAdvance():null;
if(fx)this.openMatchModal(fx);
else this.refreshRoute();
},
/* ================= DASHBOARD ================= */
rDash(){
const me=Game.me();
const rank=Game.rankOf(me.id);
const liveEv=G.calendar.find(e=>e.status==='live'&&e.teams.includes(me.id));
const upc=G.calendar.filter(e=>e.status!=='done'&&e.startDay>G.day-10).slice(0,5);
const recent=[];
G.calendar.forEach(e=>e.rounds.forEach(r=>r.matches.forEach(m=>{
if(m.a&&m.b&&(m.a===me.id||m.b===me.id)&&m.winner)recent.push({m,e});
})));
recent.sort((a,b)=>b.m.day-a.m.day);
const obj=Game.objectives();
let html=`<h1 style="font-size:22px">Club Dashboard</h1>
<div class="stat-tiles mt">
<div class="tile"><div class="t-label">Balance</div><div class="t-val ${G.money>=0?'good':'bad'}">${U.fmtM(G.money)}</div></div>
<div class="tile"><div class="t-label">World Rank</div><div class="t-val">#${rank}</div></div>
<div class="tile"><div class="t-label">Fans</div><div class="t-val">${U.fmtNum(G.fans)}</div></div>
<div class="tile"><div class="t-label">Reputation</div><div class="t-val">${Math.round(G.rep)}</div></div>
<div class="tile"><div class="t-label">Board</div><div class="t-val" style="color:${G.board>50?'var(--good)':G.board>25?'var(--warn)':'var(--bad)'}">${Math.round(G.board)}%</div></div>
<div class="tile"><div class="t-label">Chemistry</div><div class="t-val">${Math.round(me.fam)}%</div></div>
</div>
<div class="card mt"><h3>🎯 Season ${G.season} Objectives</h3>
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(230px,1fr))">
<div><div class="kv"><span>World ranking</span><b class="${obj.rankOk?'good':'warn'}">#${obj.rank} · target TOP ${obj.targetRank}</b></div>
<div class="bar"><i style="width:${U.clamp(obj.targetRank/Math.max(rank,1)*100,4,100)}%;background:${obj.rankOk?'var(--good)':'var(--warn)'}"></i></div></div>
<div><div class="kv"><span>Finances</span><b class="${obj.moneyOk?'good':'bad'}">${obj.moneyOk?'solvent ⚖️':'deficit ⚠️'}</b></div>
<div class="bar"><i style="width:${U.clamp((G.money+200000)/1700000*100,2,100)}%;background:${obj.moneyOk?'var(--good)':'var(--bad)'}"></i></div></div>
<div><div class="kv"><span>Board confidence</span><b>${obj.board}%</b></div>
<div class="bar"><i style="width:${obj.board}%;background:${G.board>50?'var(--good)':G.board>25?'var(--warn)':'var(--bad)'}"></i></div></div>
</div></div>
<div class="card-grid mt">`;
html+=`<div class="card"><h3>${liveEv?'🔴 LIVE — '+U.esc(liveEv.name):'📅 Upcoming'}</h3>`;
if(liveEv){
html+=this.bracketMini(liveEv)+
`<button class="btn btn-accent btn-sm mt" onclick="UI.eventDetail('${liveEv.id}')">Open bracket</button>`;
}else{
const nxt=G.calendar.find(e=>e.registered&&e.status!=='done'&&e.startDay>G.day);
html+=nxt?`<p>${U.esc(nxt.name)} starts <b>${U.fmtDate(nxt.startDay)}</b>.</p>
<p class="muted small">Tier ${nxt.tier} · ${U.fmtM(nxt.prize)} to the winner · ${nxt.slots} teams${nxt.major?' · MAJOR 🏆':''}</p>`
:`<p class="muted">No event registered.</p><button class="btn btn-accent btn-sm" onclick="UI.go('tournaments')">Browse Tournaments →</button>`;
html+=`<h3 class="mt">Calendar ahead</h3>`+upc.map(e=>
`<div class="kv"><span>${U.esc(e.name)}${e.registered?' <span class="cy">✓</span>':''}</span><span class="muted small">${U.fmtDateShort(e.startDay)} · T${e.tier}</span></div>`).join('');
}
html+=`</div>`;
const sorted=Game.teamPlayers(me).sort((a,b)=>playerOverall(b)-playerOverall(a));
const trophies=Object.entries(G.achievements).filter(([k])=>k.startsWith('trophy_'));
const expiring=Game.teamPlayers(me)
.filter(p=>p.contractUntil>G.day&&p.contractUntil-G.day<=150)
.sort((a,b)=>a.contractUntil-b.contractUntil).slice(0,5);
html+=`<div class="card"><h3>🏆 Trophy Cabinet</h3>${
G.achievements.grandSlam?'<p class="gold">💎 GRAND SLAM CHAMPION</p>':''}${
trophies.length?trophies.map(([k,v])=>`<div class="kv"><span>🥆 ${U.esc(v.ev)}</span><span class="tiny muted">${U.fmtDateShort(v.day)}</span></div>`).join('')
:'<p class="muted small">Empty shelf. The first trophy tastes the sweetest.</p>'
}<h3 class="mt">⏳ Expiring Contracts</h3>${
expiring.map(p=>{const left=p.contractUntil-G.day;
return `<div class="kv clickable" onclick="UI.playerModal('${p.id}')"><span>${p.nick} <span class="tiny muted">${playerOverall(p).toFixed(0)}</span></span><span class="${left<45?'bad':left<90?'warn':'muted'} tiny">${left}d</span></div>`;}).join('')
||'<p class="good tiny">All contracts secure ✔</p>'
}</div>`;
html+=`<div class="card"><h3>🏅 Career Milestones</h3>${Game.MILESTONES.map(m=>{
const got=G.milestones.includes(m.id);
return `<div class="kv" title="${U.esc(m.desc)}"><span>${got?'✅':'🔒'} ${m.name}</span><span class="tiny ${got?'gold':'muted'}">${got?'unlocked':U.esc(m.desc)}</span></div>`;
}).join('')}</div>`;
html+=`<div class="card"><h3>Squad Snapshot</h3><table class="tbl"><tr><th></th><th>Player</th><th>Form</th><th>Morale</th><th>Fatigue</th></tr>${
sorted.slice(0,6).map(p=>`<tr class="clickable" onclick="UI.playerModal('${p.id}')">
<td><span class="ovr ${ovrClass(playerOverall(p))}">${playerOverall(p).toFixed(0)}</span></td>
<td><span class="nick">${p.nick}</span> ${FLAG(p.nat)}<br><span class="tiny muted">${p.role}</span></td>
<td class="stars tiny">${formStars(p.form)}</td>
<td>${faceFor(p.morale)}</td>
<td><div class="bar fatigue"><i style="width:${p.fatigue}%"></i></div></td></tr>`).join('')
}</table></div>`;
html+=`<div class="card"><h3>Recent Results</h3>${
recent.slice(0,6).map(({m,e})=>{
const won=m.winner===me.id;
return `<div class="kv clickable" onclick="UI.boxScoreModal('${e.id}','${m.id}')"><span>${won?'✅':'❌'} vs ${Game.team(m.a===me.id?m.b:m.a).name}</span><span class="${won?'good':'bad'}"><b>${m.score}</b> <span class="muted tiny">${U.esc(e.name)}</span></span></div>`;
}).join('')||'<p class="muted">No matches played yet.</p>'
}</div></div>`;
html+=`<div class="card mt"><h3>Latest News</h3>${
G.news.slice(0,6).map(newsRow).join('')}</div>`;
$('view').innerHTML=html;
},
bracketMini(ev){
let html='';
ev.rounds.forEach(rnd=>{
rnd.matches.forEach(m=>{
if(!m.a||!m.b)return;
const mine=m.a===G.playerTeamId||m.b===G.playerTeamId;
html+=`<div class="kv" style="${mine?'font-weight:800':''}">
<span>${Game.team(m.a).tag} vs ${Game.team(m.b).tag}${mine?' ⭐':''}</span>
<span>${m.winner?`${m.score} <span class="muted tiny">${rnd.name}</span>`:`<span class="cy tiny">${U.fmtDateShort(m.day)}</span>`}</span></div>`;
});
});
return html||'<p class="muted">Bracket forming…</p>';
},
/* ================= SQUAD ================= */
rSquad(){
const me=Game.me();
const ps=Game.teamPlayers(me).sort((a,b)=>playerOverall(b)-playerOverall(a));
const rows=ps.map(p=>{
const starter=me.lineup.includes(p.id)?'⭐':'';
const left=p.contractUntil-G.day;
const ctr=left<45?'bad':left<120?'warn':'muted';
return `<tr class="clickable" onclick="UI.playerModal('${p.id}')">
<td><span class="ovr ${ovrClass(playerOverall(p))}">${playerOverall(p).toFixed(0)}</span></td>
<td>${starter}<span class="nick">${p.nick}</span> ${FLAG(p.nat)}${p.listedPrice!==null?' 🔁':''}<br><span class="tiny muted">${U.esc(p.name)}, ${p.age}</span></td>
<td><span class="role-tag">${p.role}</span></td>
<td class="stars tiny">${formStars(p.form)}</td>
<td>${faceFor(p.morale)} <span class="tiny muted">${Math.round(p.morale)}</span></td>
<td><div class="bar fatigue"><i style="width:${p.fatigue}%"></i></div>${p.injuryDays?`<span class="bad tiny">🏥${p.injuryDays}d</span>`:''}</td>
<td class="num">${U.fmtM(p.salary)}</td>
<td class="num">${U.fmtM(playerValue(p,G))}</td>
<td class="${ctr} tiny">${left<=0?'EXPIRED':left+'d'}</td></tr>`;
}).join('');
$('view').innerHTML=`<h1 style="font-size:22px">Squad Management</h1>
<div class="card mt"><h3>Starting Lineup</h3>
<div class="wrap">${me.lineup.map((pid,i)=>{const p=Game.p(pid);return `<span class="tagchip">${i+1}. <b>${p.nick}</b> ${playerOverall(p).toFixed(0)}${p.injuryDays?' 🏥':''}</span>`;}).join('')}
<button class="btn btn-ghost btn-sm" onclick="Game.autoLineup(Game.me());Game.checkPromisesAfterLineup();UI.toast('Best available five selected','good');UI.refreshRoute()">⚙ Auto-pick best</button>
<button class="btn btn-accent btn-sm" onclick="UI.scoutModal()">🔍 Scout prospects (${U.fmtM(Game.SCOUT_COST)})</button></div>
</div>
<div class="card mt"><table class="tbl">
<tr><th>OVR</th><th>Player</th><th>Role</th><th>Form</th><th>Morale</th><th>Fatigue</th><th class="num">Wage/mo</th><th class="num">Value</th><th>Contract</th></tr>
${rows}</table></div>
<p class="tiny muted mt">Roster cap: 8 players (${ps.length}/8).</p>`;
},
playerModal(pid){
const p=Game.p(pid);
const mine=p.teamId===G.playerTeamId;
const ovr=playerOverall(p);
const inLineup=mine&&Game.me().lineup.includes(pid);
const left=p.contractUntil-G.day;
const attrs=[['Aim',p.attrs.aim],['Positioning',p.attrs.pos],['Clutch',p.attrs.clutch],['Utility',p.attrs.util],['Gamesense',p.attrs.iq],['Movement',p.attrs.move],['Communication',p.attrs.comm]];
const s=p.seasonStats,r=s.maps?s.ratingSum/s.maps:0;
const bar=(label,val,max=100)=>`<div class="kv"><span>${label}</span><span style="min-width:130px"><div class="bar"><i style="width:${U.clamp(val/max*100,2,100)}%"></i></div></span></div>`;
const html=`<button class="close-x" onclick="UI.closeModal()">×</button>
<div class="flex"><span class="ovr ${ovrClass(ovr)}" style="min-width:52px;height:52px;font-size:24px">${ovr.toFixed(0)}</span>
<div><h2>${p.nick} ${FLAG(p.nat)}</h2><div class="muted">${U.esc(p.name)} · ${p.age}y · ${p.role}${p.potential>ovr+4&&mine?` · <span class="cy">POT ${p.potential}</span>`:''}</div></div></div>
<div class="card-grid mt">
<div class="card"><h3>Attributes</h3>${attrs.map(([l,vv])=>bar(l,vv)).join('')}
<div class="kv mt"><span>Progress to potential</span><span style="min-width:130px"><div class="bar"><i style="width:${U.clamp(ovr/Math.max(p.potential,1)*100,5,100)}%;background:${ovr>=p.potential-2?'var(--gold)':'var(--accent2)'}"></i></div></span></div>
<div class="mt small muted">Form ${formStars(p.form)} · Morale ${faceFor(p.morale)} ${Math.round(p.morale)} · Fatigue ${Math.round(p.fatigue)}%${p.injuryDays?` · <span class="bad">Injured ${p.injuryDays}d</span>`:''}</div>
<div class="tiny muted mt">Professionalism ${p.personality.prof} · Temperament ${p.personality.temper} · Ambition ${p.personality.ambition}</div></div>
<div class="card"><h3>Season Stats</h3>
<div class="stat-tiles">
<div class="tile"><div class="t-label">Maps</div><div class="t-val">${s.maps}</div></div>
<div class="tile"><div class="t-label">Rating</div><div class="t-val">${r?r.toFixed(2):'—'}</div></div>
<div class="tile"><div class="t-label">K/D</div><div class="t-val">${s.maps&&s.d?(s.k/s.d).toFixed(2):'—'}</div></div>
<div class="tile"><div class="t-label">ADR</div><div class="t-val">${s.maps?Math.round(s.adr):'—'}</div></div>
<div class="tile"><div class="t-label">MVPs</div><div class="t-val">${s.mvp}</div></div>
<div class="tile"><div class="t-label">Clutches</div><div class="t-val">${s.aces}</div></div>
</div>
<div class="mt kv"><span>Contract</span><span>${mine?(left>0?left+' days left':`expired ${-left}d ago`):U.fmtDate(p.contractUntil)}</span></div>
<div class="kv"><span>Value ≈</span><b>${U.fmtM(playerValue(p,G))}</b></div>
</div>
</div>
${mine?`<div class="flex wrap mt">
<button class="btn btn-accent btn-sm" onclick="UI.talkModal('${pid}')">💬 Talk</button>
${inLineup
?`<button class="btn btn-ghost btn-sm" onclick="Game.me().lineup=Game.me().lineup.filter(x=>x!=='${pid}');Game.checkPromisesAfterLineup();UI.closeModal();UI.refreshRoute()">Bench</button>`
:`<button class="btn btn-good btn-sm" onclick="UI.addToLineup('${pid}')">Add to Lineup</button>`}
<button class="btn btn-primary btn-sm" onclick="UI.negotiateModal('${pid}','renew')">🖊 Renew Contract</button>
${p.listedPrice!==null?`<button class="btn btn-ghost btn-sm" onclick="Game.unlistPlayer('${pid}');UI.toast('Removed from transfer list');UI.closeModal();UI.refreshRoute()">Unlist</button>`
:`<button class="btn btn-ghost btn-sm" onclick="UI.closeModal();UI.listModal('${pid}')">🔁 Transfer List</button>`}
${!inLineup&&Game.me().roster.length>5?`<button class="btn btn-ghost btn-sm" onclick="UI.closeModal();UI.loanModal('${pid}')">📤 Loan Out</button>`:''}
<button class="btn btn-danger btn-sm" onclick="UI.confirmRelease('${pid}')">Release</button>
</div>`:
`<div class="flex wrap mt">
${p.teamId?`<button class="btn btn-primary btn-sm" onclick="UI.negotiateModal('${pid}','buy')">💰 Make Transfer Offer</button>`
:`<button class="btn btn-good btn-sm" onclick="UI.negotiateModal('${pid}','fa')">✍ Sign Free Agent</button>`}
<span class="muted small">Wage demand ≈ ${U.fmtM(wageDemand(p,G))}/mo${p.listedPrice!==null?` · asking ${U.fmtM(p.listedPrice)}`:''}</span>
</div>`}`;
this.modal(html,true);
},
addToLineup(pid){
const me=Game.me();
if(me.lineup.length>=5){this.toast('Lineup is full — bench someone first.','warn');return;}
if(!me.lineup.includes(pid))me.lineup.push(pid);
Game.checkPromisesAfterLineup();
this.closeModal();this.refreshRoute();
},
confirmRelease(pid){
const p=Game.p(pid);
if(confirm(`Release ${p.nick}? A severance payment applies.`)){
const r=Game.releasePlayer(pid);
this.toast(r.ok?`${p.nick} released.`:r.msg,r.ok?'good':'bad');
this.closeModal();this.refreshRoute();
}
},
listModal(pid){
const p=Game.p(pid);
const val=playerValue(p,G);
const html=`<h2>Transfer List — ${p.nick}</h2><p class="muted small">Market value: ${U.fmtM(val)}. Listed players attract bids from other clubs (see Inbox / Transfers → Bids).</p>
<label class="small muted">Asking price</label>
<input id="listPrice" type="number" step="50000" value="${val}" style="width:100%">
<div class="flex mt"><button class="btn btn-accent" onclick="Game.listPlayer('${pid}',parseInt(document.getElementById('listPrice').value)||${val});UI.toast('${p.nick} listed for transfer','good');UI.closeModal();UI.refreshRoute()">Confirm listing</button>
<button class="btn btn-ghost" onclick="UI.closeModal()">Cancel</button></div>`;
this.modal(html);
},
scoutModal(){
const r=Game.scoutYouth();
if(r.ok===false){UI.toast(r.msg,'warn');return;}
this.updateTopbar();
const card=p=>{
const ovr=playerOverall(p);
const attrs=[['AIM',p.attrs.aim],['POS',p.attrs.pos],['CLU',p.attrs.clutch],['UTL',p.attrs.util],['IQ',p.attrs.iq],['MOV',p.attrs.move],['COM',p.attrs.comm]];
return `<div class="offer-card"><div class="flex spread">
<div><b>${FLAG(p.nat)} ${p.nick}</b> <span class="tiny muted">${p.age}y · ${p.role} · ${NATIONS[p.nat]||p.nat}</span><br>
<span class="ovr ${ovrClass(ovr)}">${ovr.toFixed(0)}</span> <span class="tiny gold">POT ${p.potential}★</span></div>
<button class="btn btn-primary btn-sm" onclick="const rr=Game.signProspect('${p.id}');if(rr&&rr.ok===false){UI.toast(rr.msg,'warn');}else{UI.toast('${p.nick} joins the academy!','good');UI.closeModal();UI.refreshRoute();}">Sign (3y)</button></div>
<div class="tiny muted mt" style="letter-spacing:1px">${attrs.map(([l,v])=>`${l} ${v.toFixed(0)}`).join(' · ')}</div>
</div>`;
};
this.modal(`<h2>🔍 Scouting Camp — $80K</h2>
<p class="muted small">Three unattached youngsters caught our scouts' eyes. Sign one — the others return to obscurity. Potential scales with club reputation.</p>
<div class="mt">${r.list.map(card).join('')}</div>
<div class="mt"><button class="btn btn-ghost btn-sm" onclick="UI.closeModal()">Walk away</button></div>`);
},
loanModal(pid){ const p=Game.p(pid);
const clubs=Object.values(G.teams).filter(t=>!t.isPlayer&&t.id!==p.teamId)
.sort((a,b)=>a.ratingPts-b.ratingPts).slice(0,8);
const html=`<h2>Development Loan — ${p.nick}</h2>
<p class="muted small">Loaning a young player out for 90 days earns a small fee and accelerates his growth through playing time. He returns automatically afterwards.</p>
<div class="mt">${clubs.map(t=>`
<div class="offer-card flex spread">
<div><b style="color:${t.color}">${U.esc(t.name)}</b> <span class="tiny muted">Tier ${t.tier} · #${Game.rankOf(t.id)} · roster ${t.roster.length}/8</span></div>
<button class="btn btn-primary btn-sm" onclick="const r=Game.sendOnLoan('${pid}','${t.id}');if(r&&r.ok===false){UI.toast(r.msg,'warn');}else{UI.toast('${p.nick} loaned to ${U.esc(t.tag)}','good');}UI.closeModal();UI.refreshRoute()">Loan</button>
</div>`).join('')}</div>
<div class="flex mt"><button class="btn btn-ghost" onclick="UI.closeModal()">Cancel</button></div>`;
this.modal(html);
},
/* ---------- talk module ---------- */
talkModal(pid){
const p=Game.p(pid);
const topics=[['praise','👏 Praise his form'],['motivate','🔥 Motivate him'],['role','🧩 Discuss role'],
['discipline','⚠️ Demand professionalism'],['promise','🤝 Promise a starting spot'],['contract','💵 Hint at a raise']];
const tones=['supportive','neutral','demanding'];
const html=`<button class="close-x" onclick="UI.closeModal()">×</button>
<h2>Talk to ${p.nick}</h2>
<p class="muted small">Morale ${faceFor(p.morale)} ${Math.round(p.morale)} · Motivation ${Math.round(p.motivation)} · Form ${formStars(p.form)}. One meaningful talk per ~10 days.</p>
<h3>Topic</h3><div class="tac-grid" id="talkTopics">${
topics.map(([id,l])=>`<button class="choice" data-t="${id}" onclick="document.querySelectorAll('#talkTopics .choice').forEach(b=>b.classList.remove('on'));this.classList.add('on')"><b>${l}</b></button>`).join('')
}</div>
<h3>Tone</h3><div class="seg" id="talkTones">${tones.map((t,i)=>`<button class="${i===1?'on':''}" data-t="${t}" onclick="document.querySelectorAll('#talkTones button').forEach(b=>b.classList.remove('on'));this.classList.add('on')">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')}</div>
<div class="flex mt"><button class="btn btn-accent" id="doTalk">Have the conversation</button>
<button class="btn btn-ghost" onclick="UI.closeModal()">Not now</button></div>
<div id="talkResult" class="mt"></div>`;
this.modal(html);
$('doTalk').onclick=()=>{
const topic=document.querySelector('#talkTopics .on')?.dataset.t;
const tone=document.querySelector('#talkTones .on')?.dataset.t||'neutral';
if(!topic){$('talkResult').innerHTML='<p class="bad">Pick a topic first.</p>';return;}
const res=Game.applyTalk(pid,topic,tone);
$('talkResult').innerHTML=`<div class="offer-card"><p>${U.esc(res.msg)}</p>
${res.dM!==undefined?`<p class="tiny muted">Morale ${res.dM>=0?'+':''}${res.dM} · Motivation ${res.dMo>=0?'+':''}${res.dMo}</p>`:''}
<button class="btn btn-ghost btn-sm mt" onclick="UI.closeModal();UI.refreshRoute()">Done</button></div>`;
this.updateTopbar();
};
},
/* ---------- negotiation ---------- */
negotiateModal(pid,kind){
const p=Game.p(pid);
const demand=wageDemand(p,G);
const defSalary=Math.max(kind==='renew'?p.salary:0,Math.round(demand/1000)*1000);
const title=kind==='renew'?`Renew — ${p.nick}`:(kind==='fa'?`Sign free agent ${p.nick}`:`Bid for ${p.nick} (${U.esc(Game.team(p.teamId)?.tag||'')})`);
const feeField=kind==='buy'?`<label class="small muted">Transfer fee (value ≈ ${U.fmtM(playerValue(p,G))})<input id="negFee" type="number" step="50000" value="${playerValue(p,G)}"></label>`:'';
const html=`<button class="close-x" onclick="UI.closeModal()">×</button>
<h2>${title}</h2>
<p class="muted small">His camp wants ≈ <b>${U.fmtM(demand)}</b>/mo. Current club: ${p.teamId?U.esc(Game.team(p.teamId).name):'free agent'} · morale ${faceFor(p.morale)} · OVR ${playerOverall(p).toFixed(0)}</p>
<div class="form-grid">
${feeField}
<label class="small muted">Monthly wage<input id="negSalary" type="number" step="500" value="${defSalary}"></label>
<label class="small muted">Years<input id="negYears" type="number" min="1" max="5" value="2"></label>
<label class="small muted">Signing bonus<input id="negBonus" type="number" step="25000" value="0"></label>
</div>
<div class="flex mt"><button class="btn btn-accent" id="negGo">Submit offer</button>
<button class="btn btn-ghost" onclick="UI.closeModal()">Cancel</button></div>
<div id="negResult" class="mt"></div>`;
this.modal(html);
$('negGo').onclick=()=>{
const salary=parseInt($('negSalary').value)||demand;
const years=U.clamp(parseInt($('negYears').value)||2,1,5);
const bonus=parseInt($('negBonus').value)||0;
let res;
if(kind==='renew')res=Game.renewContract(pid,salary,years,bonus);
else if(kind==='fa')res=Game.signFreeAgent(pid,salary,years,bonus);
else{
const fee=parseInt($('negFee')?.value)||playerValue(p,G);
res=Game.makeTransferOffer(pid,fee,salary,years);
}
if(res.ok){this.toast(res.msg,'good');this.closeModal();this.refreshRoute();}
else $('negResult').innerHTML=`<div class="offer-card">❌ ${U.esc(res.msg)}
<div class="mt"><button class="btn btn-ghost btn-sm" onclick="UI.closeModal()">Close</button></div></div>`;
};
},
/* ================= TRAINING ================= */
rTraining(){
const me=Game.me();
const coach=Game.staffOf(me,'Coach'),physio=Game.staffOf(me,'Physio');
const rowHtml=Game.teamPlayers(me).map(p=>{
const focusSel=TRAINING_FOCUS.map(f=>`<option ${p.training.focus===f?'selected':''}>${f}</option>`).join('');
const intSel=[[0,'Light'],[1,'Normal'],[2,'Hard']].map(([v,l])=>`<option value="${v}" ${p.training.intensity===v?'selected':''}>${l}</option>`).join('');
return `<tr>
<td><b>${p.nick}</b> <span class="tiny muted">${p.role}</span></td>
<td><span class="ovr" style="min-width:28px;height:28px;font-size:13px">${playerOverall(p).toFixed(0)}</span> <span class="tiny cy">→ ${p.potential}</span></td>
<td><select onchange="Game.setTraining('${p.id}',this.value);UI.refreshRoute()">${focusSel}</select></td>
<td><select onchange="Game.setTraining('${p.id}',undefined,parseInt(this.value));UI.refreshRoute()">${intSel}</select></td>
<td><div class="bar fatigue"><i style="width:${p.fatigue}%"></i></div></td>
<td>${p.injuryDays?`<span class="bad">🏥 ${p.injuryDays}d</span>`:'<span class="good">✔</span>'}</td></tr>`;
}).join('');
const bootcamping=(G.flags.bootcampUntil||0)>G.day;
$('view').innerHTML=`<h1 style="font-size:22px">Training Center</h1>
<div class="card-grid mt">
<div class="card"><h3>Weekly Plan</h3><p class="muted small">Focus each player's week. Hard intensity grows skills fastest but piles up fatigue — injury risk rises above 86%. Rest recovers body and mind. Media weeks grow your fanbase.</p></div>
<div class="card"><h3>Support Effects</h3>
<div class="kv"><span>Coach</span><span>${coach?`<b>${coach.nick}</b> (+${Math.round(coach.skill-55)}% growth)`:'<span class="bad">vacant</span>'}</span></div>
<div class="kv"><span>Physio</span><span>${physio?`<b>${physio.nick}</b> (faster recovery)`:'<span class="bad">vacant</span>'}</span></div>
<div class="kv"><span>Chemistry</span><span>${Math.round(me.fam)}%</span></div>
<button class="btn btn-accent btn-sm mt" onclick="UI.doBootcamp()" ${bootcamping?'disabled':''}>🏕 Bootcamp week ($20K)</button>
${bootcamping?'<p class="tiny good mt">Bootcamp in progress…</p>':''}
</div>
</div>
<div class="card mt"><table class="tbl"><tr><th>Player</th><th>Level</th><th>Focus</th><th>Intensity</th><th>Fatigue</th><th>Status</th></tr>${rowHtml}</table></div>`;
},
doBootcamp(){
const r=Game.bootcamp();
if(r==='nomoney')this.toast('Not enough money.','bad');
else if(r===false)this.toast('Already bootcamping.','warn');
else this.toast('Bootcamp booked — chemistry rising.','good');
this.refreshRoute();
},
/* ================= TRANSFERS ================= */
_marketFilters:{q:'',role:'',maxAge:99,minOvr:0,sortBy:'ovr'},
rTransfers(){
const f=this._marketFilters;
const tab=G.ui.marketTab||'market';
const win=Game.inTransferWindow();
const d=((G.day-1)%364)+1;
let winTxt;
if(d>=150&&d<=230)winTxt=`window closes in ${230-d}d`;
else if(d>=300)winTxt=`winter window · ${365-d}d left`;
else winTxt=`opens in ${150-d}d`;
let body='';
if(tab==='offers'){
body=G.pendingOffers.map((b,i)=>{
const p=Game.p(b.pid),from=Game.team(b.from);
return `<div class="offer-card"><div class="flex spread wrap">
<div><b>${p.nick}</b> <span class="tiny muted">${p.role} · ${playerOverall(p).toFixed(0)} OVR · value ${U.fmtM(playerValue(p,G))}</span><br>
<span class="small">${U.esc(from.name)} offers <b class="gold">${U.fmtM(b.fee)}</b> + ${U.fmtM(b.wage)}/mo wages</span></div>
<div class="flex"><button class="btn btn-good btn-sm" onclick="Game.respondBid(${i},true);UI.toast('Sale completed 💰','good');UI.refreshRoute()">Accept</button>
<button class="btn btn-danger btn-sm" onclick="Game.respondBid(${i},false);UI.refreshRoute()">Reject</button></div></div></div>`;
}).join('')||'<p class="muted">No incoming bids right now.</p>';
}else{
let list=Object.values(G.players).filter(p=>{
if(p.loanFrom)return false;
const isFA=p.teamId===null;
const isListed=p.listedPrice!==null&&p.teamId!==G.playerTeamId;
if(!isFA&&!isListed)return false;
if(f.role&&p.role!==f.role)return false;
if(p.age>f.maxAge)return false;
if(playerOverall(p)<f.minOvr)return false;
if(f.q&&!(p.nick.toLowerCase().includes(f.q.toLowerCase())||p.name.toLowerCase().includes(f.q.toLowerCase())))return false;
return true;
});
const sorters={ovr:(a,b)=>playerOverall(b)-playerOverall(a),val:(a,b)=>playerValue(b,G)-playerValue(a,G),age:(a,b)=>a.age-b.age,pot:(a,b)=>b.potential-a.potential};
list.sort(sorters[f.sortBy]);
body=list.slice(0,60).map(p=>{
const isFA=p.teamId===null;
return `<tr class="clickable" onclick="UI.playerModal('${p.id}')">
<td><span class="ovr ${ovrClass(playerOverall(p))}">${playerOverall(p).toFixed(0)}</span></td>
<td><b>${p.nick}</b> ${FLAG(p.nat)}<br><span class="tiny muted">${U.esc(p.name)}, ${p.age}</span></td>
<td><span class="role-tag">${p.role}</span></td>
<td class="num tiny cy">${p.potential}</td>
<td class="num">${U.fmtM(playerValue(p,G))}</td>
<td class="num">${isFA?'<span class="good">FREE AGENT</span>':U.esc(Game.team(p.teamId)?.tag||'—')}</td>
<td class="num tiny muted">${isFA?U.fmtM(wageDemand(p,G))+'/mo':(p.listedPrice?U.fmtM(p.listedPrice):'—')}</td></tr>`;
}).join('')||'<tr><td colspan="7" class="muted">Nobody matches those filters.</td></tr>';
}
$('view').innerHTML=`<h1 style="font-size:22px">Transfer Center <span class="tiny ${win?'good':'muted'}" style="font-weight:400">· ${win?'🟢 transfer window open':'⚪ closed'} (${winTxt})</span></h1>
<div class="flex mt mb wrap">
<span class="seg">
<button class="${tab==='market'?'on':''}" onclick="G.ui.marketTab='market';UI.refreshRoute()">Market</button>
<button class="${tab==='offers'?'on':''}" onclick="G.ui.marketTab='offers';UI.refreshRoute()">Incoming Bids${G.pendingOffers.length?` <span class="pill">${G.pendingOffers.length}</span>`:''}</button>
</span>
${tab==='market'?`
<input placeholder="Search…" value="${U.esc(f.q)}" oninput="UI._marketFilters.q=this.value;clearTimeout(UI._qT);UI._qT=setTimeout(()=>UI.refreshRoute(),300)">
<select onchange="UI._marketFilters.role=this.value;UI.refreshRoute()">
<option value="">All roles</option>${ROLES.map(r=>`<option ${f.role===r?'selected':''}>${r}</option>`).join('')}</select>
<select onchange="UI._marketFilters.minOvr=parseInt(this.value);UI.refreshRoute()">
${[0,70,75,80,85].map(v=>`<option value="${v}" ${f.minOvr===v?'selected':''}>${v?('OVR ≥ '+v):'Any OVR'}</option>`).join('')}</select>
<select onchange="UI._marketFilters.maxAge=parseInt(this.value);UI.refreshRoute()">
${[[99,'Any age'],[20,'≤20'],[23,'≤23'],[26,'≤26'],[30,'≤30']].map(([v,l])=>`<option value="${v}" ${f.maxAge===v?'selected':''}>${l}</option>`).join('')}</select>
<select onchange="UI._marketFilters.sortBy=this.value;UI.refreshRoute()">
${[['ovr','Sort: OVR'],['pot','Sort: Potential'],['val','Sort: Value'],['age','Sort: Age']].map(([v,l])=>`<option value="${v}" ${f.sortBy===v?'selected':''}>${l}</option>`).join('')}</select>`:''}
</div>
${tab==='market'?`<div class="card"><table class="tbl">
<tr><th>OVR</th><th>Player</th><th>Role</th><th class="num">Pot</th><th class="num">Value</th><th>Club</th><th class="num">Price</th></tr>
${body}</table></div>`:`<div>${body}</div>`}`;
},
/* ================= TOURNAMENTS ================= */
rTours(){
const me=Game.me();
const evs=G.calendar.filter(e=>e.startDay>G.day-40).sort((a,b)=>a.startDay-b.startDay);
const groups={open:[],locked:[],live:[],done:[]};
evs.forEach(e=>groups[e.status]?.push(e));
const card=e=>{
const regd=e.registered,inField=e.teams.includes(me.id);
return `<div class="card" style="border-left:4px solid ${e.major?'var(--gold)':e.tier===1?'var(--accent)':'var(--line)'}">
<div class="flex spread"><h3 style="margin:0">${U.esc(e.name)} ${e.major?'🏆':''}${e.gs?' <span class="cy" title="Grand Slam event">💎</span>':''}</h3>
<span class="tagchip">Tier ${e.tier}</span></div>
<div class="kv"><span>Starts</span><b>${U.fmtDate(e.startDay)}</b></div>
<div class="kv"><span>Winner prize</span><b class="gold">${U.fmtM(e.prize)}</b></div>
<div class="kv"><span>Points</span><b>${e.pts}</b></div>
<div class="kv"><span>Teams</span><b>${e.teams.length||e.slots}</b></div>
${e.status==='open'?(regd?'<p class="good small mt">✓ Registered — field locks soon</p>'
:`<button class="btn btn-accent btn-sm mt" onclick="UI.registerEvent('${e.id}')">Register team</button>`)
:e.status==='live'?`<p class="cy small mt">● LIVE${inField?' — you are competing!':''}</p><button class="btn btn-ghost btn-sm mt" onclick="UI.eventDetail('${e.id}')">View bracket</button>`
:`<p class="muted small mt">Champion: <b>${e.champion?U.esc(Game.team(e.champion).name):'—'}</b></p><button class="btn btn-ghost btn-sm mt" onclick="UI.eventDetail('${e.id}')">Results</button>`}
</div>`;
};
$('view').innerHTML=`<h1 style="font-size:22px">Tournament Circuit</h1>
${G.achievements.grandSlam?'<p class="gold">💎 GRAND SLAM CHAMPION 💎</p>':`<p class="muted small">Win ${GS_REQUIRED} of the 💎 elite events consecutively within a year for the Grand Slam (${U.fmtM(GS_BONUS)} bonus).</p>`}
${groups.live.length?`<h3 class="mt">Happening now</h3><div class="card-grid">${groups.live.map(card).join('')}</div>`:''}
<h3 class="mt">Upcoming & open</h3>
<div class="card-grid">${groups.open.concat(groups.locked).map(card).join('')||'<p class="muted">Nothing open right now.</p>'}</div>
<h3 class="mt">Recently finished</h3><div class="card-grid">${groups.done.slice(-4).reverse().map(card).join('')||'<p class="muted">—</p>'}</div>`;
},
registerEvent(id){
const r=Game.registerEvent(id);
if(r&&!r.ok)this.toast(r.msg,'warn');
else this.toast('Registered ✔','good');
this.refreshRoute();
},
eventDetail(id){
const ev=G.calendar.find(e=>e.id===id);if(!ev)return;
let html=`<button class="close-x" onclick="UI.closeModal()">×</button>
<h2>${U.esc(ev.name)} ${ev.major?'🏆':''}</h2>
<p class="muted small">${U.fmtDate(ev.startDay)} · Tier ${ev.tier} · ${U.fmtM(ev.prize)} winner prize · ${ev.pts} pts</p>`;
if(ev.champion)html+=`<p>🥇 Champion: <b>${U.esc(Game.team(ev.champion).name)}</b></p>`;
html+='<div class="mt">';
ev.rounds.forEach(rnd=>{
html+=`<h3>${rnd.name} <span class="tiny muted">${U.fmtDateShort(rnd.day)}</span></h3>`;
rnd.matches.forEach(m=>{
if(!m.a||!m.b){html+=`<div class="kv muted"><span>TBD vs TBD</span></div>`;return;}
const A=Game.team(m.a),B=Game.team(m.b);
const mine=m.a===G.playerTeamId||m.b===G.playerTeamId;
const winIsA=m.winner===m.a;
html+=`<div class="kv" style="${mine?'font-weight:800;background:rgba(255,122,26,.06)':''}">
<span>${winIsA?'<b>':''}${A.tag}${winIsA?'</b>':''} vs ${m.winner&&!winIsA?'<b>':''}${B.tag}${m.winner&&!winIsA?'</b>':''} ${mine?'⭐':''}</span>
<span>${m.winner?`<b>${m.score}</b> <span class="tiny muted">${(m.maps||[]).map(x=>`${x.map} ${x.score}`).join(' · ')}</span>`:`<span class="cy tiny">${U.fmtDateShort(m.day)} · BO${m.bo}</span>`}</span></div>`;
});
});
html+='</div>';
const pending=Game.findUserFixtureToday();
html+=`<div class="flex mt">${pending&&ev.status==='live'?`<button class="btn btn-accent" onclick="UI.closeModal();UI.openMatchModal(Game.findUserFixtureToday())">▶ Play today's match</button>`:''}
<button class="btn btn-ghost" onclick="UI.closeModal()">Close</button></div>`;
this.modal(html,true);
},
/* ================= MATCHES ================= */
rMatches(){
const me=Game.me();
const past=[];const future=[];
G.calendar.forEach(e=>e.rounds.forEach(r=>r.matches.forEach(m=>{
if(!m.a||!m.b)return;
if(m.a===me.id||m.b===me.id)(m.winner?past:future).push({m,e});
})));
past.sort((a,b)=>b.m.day-a.m.day);
const opp=m=>Game.team(m.a===me.id?m.b:m.a);
$('view').innerHTML=`<h1 style="font-size:22px">Matches</h1>
<div class="card mt"><h3>Upcoming</h3>${
future.sort((a,b)=>a.m.day-b.m.day).map(({m,e})=>`<div class="kv"><span>vs <b>${U.esc(opp(m).name)}</b> <span class="tiny muted">BO${m.bo} · ${U.esc(e.name)}</span></span>
<span class="cy tiny">${U.fmtDate(m.day)}</span></div>`).join('')||'<p class="muted">Register for events to schedule matches.</p>'}</div>
<div class="card mt"><h3>History</h3>
<table class="tbl"><tr><th>Date</th><th>Event</th><th>Opponent</th><th>Result</th><th>Score</th></tr>
${past.map(({m,e})=>{
const won=m.winner===me.id;
return `<tr class="clickable" onclick="UI.boxScoreModal('${e.id}','${m.id}')">
<td class="tiny muted">${U.fmtDateShort(m.day)}</td><td class="tiny">${U.esc(e.name)}</td>
<td>${U.esc(opp(m).tag)}</td><td class="${won?'good':'bad'}">${won?'WIN':'LOSS'}</td><td><b>${m.score}</b></td></tr>`;
}).join('')}</table>
${past.length?'':'<p class="muted">No matches yet.</p>'}</div>`;
},
boxScoreModal(evId,mid){
const ev=G.calendar.find(e=>e.id===evId);if(!ev)return;
let m=null;
for(const r of ev.rounds)for(const x of r.matches)if(x.id===mid)m=x;
if(!m||!m.winner)return;
this.boxScoreInline(m,ev);
},
boxScoreInline(m,ev,opts={}){
const la=MatchEngine.lineupOf(m.a),lb=MatchEngine.lineupOf(m.b);
const tbl=(tid,lineup)=>`<h3>${U.esc(Game.team(tid).name)} ${m.winner===tid?'🏆':''}</h3><table class="tbl">
<tr><th>Player</th><th class="num">K</th><th class="num">D</th><th class="num">ADR</th><th class="num">Rating</th></tr>
${lineup.map(pid=>{const b=m.box[pid];if(!b)return'';const p=Game.p(pid);
const rt=(b.ratingSum||1)/Math.max(b.mapsN||1,1);
return `<tr ${m.mvp===pid?'style="color:var(--gold)"':''}><td>${m.mvp===pid?'🏅 ':''}${p.nick}</td>
<td class="num">${b.k}</td><td class="num">${b.d}</td><td class="num">${Math.round(b.adr/Math.max(b.mapsN||1,1))}</td><td class="num"><b>${rt.toFixed(2)}</b></td></tr>`;
}).join('')}</table>`;
const scoreParts=String(m.score).split('-');
this.modal(`<button class="close-x" onclick="UI.closeModal()">×</button>
<h2>${U.esc(Game.team(m.a).tag)} ${U.esc(scoreParts[0]||'')} : ${U.esc(scoreParts[1]||'')} ${U.esc(Game.team(m.b).tag)}</h2>
<p class="muted small">${ev?U.esc(ev.name||'')+' · ':''}${(m.maps||[]).map(x=>`${x.map} <b>${x.score}</b>`).join(' · ')}</p>
<div class="grid mt" style="grid-template-columns:1fr 1fr">${tbl(m.a,la)}${tbl(m.b,lb)}</div>
<div class="flex mt wrap">
${opts.continueBtn?`<button class="btn btn-primary" onclick="UI.closeModal();UI.continueAfterMatch()">Continue →</button>`:''}
<button class="btn btn-ghost" onclick="UI.closeModal()">Close</button></div>`,true);
},
/* ================= STAFF ================= */
rStaff(){
const me=Game.me();
const ROLES_STAFF=['Coach','Analyst','Psychologist','Physio','Media Manager','Finance Director'];
const cur=ROLES_STAFF.map(role=>{
const s=Game.staffOf(me,role);
return `<div class="card"><h3>${role}</h3>${s?
`<div class="flex spread"><div><b>${s.nick}</b> <span class="muted small">${U.esc(s.name)}</span><br>
<span class="stars">${'★'.repeat(Math.round(s.skill/20))}</span> <span class="muted tiny">${s.skill} skill · ${U.fmtM(s.salary)}/mo</span></div>
<button class="btn btn-danger btn-sm" onclick="UI.fireStaff('${s.id}','${role}')">Fire</button></div>`
:'<p class="muted">Vacant</p>'}</div>`;
}).join('');
const pool=Object.values(G.staff).filter(s=>!s.teamId).sort((a,b)=>b.skill-a.skill);
$('view').innerHTML=`<h1 style="font-size:22px">Backroom Staff</h1>
<p class="muted small">Coaches accelerate training, analysts sharpen prep, psychologists stabilize morale, physios cut fatigue, media managers grow fans, finance directors find savings & better sponsors.</p>
<div class="card-grid mt">${cur}</div>
<div class="card mt"><h3>Available Staff</h3><table class="tbl">
<tr><th>Name</th><th>Role</th><th>Skill</th><th class="num">Salary/mo</th><th></th></tr>
${pool.map(s=>`<tr><td><b>${s.nick}</b> <span class="tiny muted">${U.esc(s.name)}</span></td>
<td>${s.role}</td><td class="stars tiny">${'★'.repeat(Math.round(s.skill/20))} ${s.skill}</td>
<td class="num">${U.fmtM(s.salary)}</td>
<td><button class="btn btn-good btn-sm" onclick="UI.hireStaff('${s.id}')">Hire</button></td></tr>`).join('')||'<tr><td colspan="5" class="muted">Market is empty — check back later.</td></tr>'}</table></div>`;
},
hireStaff(sid){
const s=G.staff[sid];const me=Game.me();
if(G.money<s.salary*2){this.toast('Need at least 2 months of their salary banked.','bad');return;}
me.staff[s.role]=sid;s.teamId=me.id;
this.toast(`${s.nick} joins as your ${s.role}!`,'good');
this.refreshRoute();
},
fireStaff(sid,role){
const s=G.staff[sid];const me=Game.me();
if(me.staff[role]===sid){delete me.staff[role];s.teamId=null;
G.money-=s.salary; // one month severance
this.toast(`${s.nick} dismissed.`,'warn');this.refreshRoute();}
},
/* ================= FINANCE ================= */
rFinance(){
const me=Game.me();
const wagesMo=me.roster.reduce((s,pid)=>s+G.players[pid].salary,0)+Object.values(me.staff).reduce((s,sid)=>s+(G.staff[sid]?.salary||0),0);
const merchMo=Math.round((Game.lastWeeklyMerch||0)*4.33);
const sponsMo=G.sponsors.reduce((s,x)=>s+x.monthly,0);
const ops=me.tier===4?30000:45000;
const net=merchMo+sponsMo-wagesMo-ops;
$('view').innerHTML=`<h1 style="font-size:22px">Finances</h1>
<div class="stat-tiles mt">
<div class="tile"><div class="t-label">Balance</div><div class="t-val ${G.money>=0?'good':'bad'}">${U.fmtM(G.money)}</div></div>
<div class="tile"><div class="t-label">Monthly Net</div><div class="t-val" style="color:${net>=0?'var(--good)':'var(--bad)'}">${net>=0?'+':''}${U.fmtM(net)}</div></div>
<div class="tile"><div class="t-label">Wages+Staff</div><div class="t-val">-${U.fmtM(wagesMo)}</div></div>
<div class="tile"><div class="t-label">Ops Cost</div><div class="t-val">-${U.fmtM(ops)}</div></div>
<div class="tile"><div class="t-label">Merch/Stream</div><div class="t-val good">+${U.fmtM(merchMo)}</div></div>
<div class="tile"><div class="t-label">Sponsors</div><div class="t-val good">+${U.fmtM(sponsMo)}</div></div>
</div>
<div class="card-grid mt">
<div class="card"><h3>Active Partnerships (${G.sponsors.length}/3)</h3>${
G.sponsors.map(s=>`<div class="kv"><span><b>${U.esc(s.brand)}</b> <span class="tiny muted">${U.esc(s.type)}</span></span><span class="good">${U.fmtM(s.monthly)}/mo · ${Math.ceil(Math.max(0,(s.until-G.day))/28)}mo left</span></div>`).join('')||'<p class="muted">None yet.</p>'}</div>
<div class="card"><h3>Pending Offers</h3>${
G.sponsorOffers.map(o=>`<div class="offer-card"><div class="flex spread"><div><b>${U.esc(o.brand)}</b> <span class="tiny muted">${U.esc(o.type)}</span><br><span class="small">${U.fmtM(o.monthly)}/mo × ${o.months} months</span></div>
<div class="flex"><button class="btn btn-good btn-sm" onclick="if(Game.acceptSponsor('${o.id}')){UI.toast('Deal signed ✍','good');}else{UI.toast('No partner slots free.','warn');}UI.refreshRoute()">Sign</button>
<button class="btn btn-danger btn-sm" onclick="Game.rejectSponsor('${o.id}');UI.refreshRoute()">Decline</button></div></div></div>`).join('')||'<p class="muted">New offers arrive periodically based on ranking, rep & fans.</p>'}</div>
</div>`;
},
/* ================= RANKINGS ================= */
rRankings(){
const prevIdx=id=>G.rankingPrev.indexOf(id);
const rows=G.ranking.map((tid,i)=>{
const t=G.teams[tid];
const pi=prevIdx(tid);
const move=pi>=0?pi-i:null;
const arrow=move===null?'<span class="muted tiny">•</span>':move>0?`<span class="good">▲${move}</span>`:move<0?`<span class="bad">▼${-move}</span>`:'<span class="muted tiny"></span>';
return `<tr style="${t.isPlayer?'background:rgba(255,122,26,.08);font-weight:800':''}">
<td>#${i+1}</td><td>${arrow}</td>
<td><span style="display:inline-block;width:10px;height:10px;border-radius:3px;background:${t.color};margin-right:6px"></span>${U.esc(t.name)} ${t.isPlayer?'⭐':''}</td>
<td class="tiny muted">${t.region}</td><td class="num">${Math.round(t.ratingPts)}</td>
<td class="num tiny muted">${t._lastPts?`+${t._lastPts}`:''}</td></tr>`;
}).join('');
const topPlayers=Object.values(G.players).filter(p=>p.seasonStats.maps>=3)
.sort((a,b)=>(b.seasonStats.ratingSum/b.seasonStats.maps)-(a.seasonStats.ratingSum/a.seasonStats.maps)).slice(0,15);
$('view').innerHTML=`<h1 style="font-size:22px">World Rankings</h1>
<div class="card-grid mt">
<div class="card"><table class="tbl"><tr><th>#</th><th></th><th>Team</th><th>Region</th><th class="num">Points</th><th class="num">Last EV</th></tr>${rows}</table></div>
<div class="card"><h3>Player Leaderboard (season rating)</h3><table class="tbl">
<tr><th>#</th><th>Player</th><th>Club</th><th class="num">Maps</th><th class="num">Rating</th></tr>
${topPlayers.map((p,i)=>{
const rt=p.seasonStats.ratingSum/p.seasonStats.maps;
return `<tr><td>${i+1}</td><td>${p.nick} ${FLAG(p.nat)}</td>
<td class="tiny">${U.esc(Game.team(p.teamId)?.tag||'FA')}</td><td class="num">${p.seasonStats.maps}</td><td class="num"><b>${rt.toFixed(2)}</b></td></tr>`;
}).join('')||'<tr><td colspan="5" class="muted">Play some maps!</td></tr>'}</table></div>
</div>
${G.history.length?`<div class="card mt"><h3>Season History</h3><table class="tbl">
<tr><th>Season</th><th class="num">Final rank</th><th class="num">Balance</th><th class="num">Fans</th><th class="num">Trophies</th></tr>
${G.history.map(h=>`<tr><td>S${h.season}</td><td class="num">#${h.rank}</td><td class="num ${h.money>=0?'good':'bad'}">${U.fmtM(h.money)}</td><td class="num">${U.fmtNum(h.fans)}</td><td class="num">${h.trophies||0}</td></tr>`).join('')}
</table></div>`:''}`;
},
/* ================= INBOX ================= */
rInbox(){
const items=G.news.slice(0,60);
$('view').innerHTML=`<h1 style="font-size:22px">Inbox</h1>
<div class="flex mt mb"><button class="btn btn-ghost btn-sm" onclick="G.news.forEach(n=>n.read=true);UI.refreshRoute();UI.updateTopbar()">Mark all read</button></div>
<div class="card">${items.map(n=>newsRow(n,true)).join('')||'<p class="muted">Empty.</p>'}</div>`;
},
/* ================= MATCH MODALS & SIMULATION MODE ================= */
openMatchModal(fx){
window.__fx=fx;
const pv=MatchEngine.preview(fx);
const opp=Game.team(fx.a===G.playerTeamId?fx.b:fx.a);
const evName=fx.evId?G.calendar.find(e=>e.id===fx.evId)?.name:'Exhibition';
const html=`<h2>⚔️ ${U.esc(Game.me().name)} vs ${U.esc(opp.name)}</h2>
<p class="muted small">${U.esc(evName||'')} · BO${fx.bo} · ${U.fmtDate(fx.day)}</p>
<div class="card-grid mt">
<div class="card"><h3>${U.esc(Game.me().tag)} <span class="muted tiny">#${pv.rankA}</span></h3>
<p class="muted small">Avg OVR <b>${pv.sA.toFixed(1)}</b></p>
<div class="wrap mt">${Game.lineupPlayers(Game.me()).map(p=>`<span class="tagchip">${p.nick} ${playerOverall(p).toFixed(0)}${p.injuryDays?' 🏥':''}</span>`).join('')}</div></div>
<div class="card"><h3>${U.esc(opp.tag)} <span class="muted tiny">#${pv.rankB}</span></h3>
<p class="muted small">Avg OVR <b>${pv.sB.toFixed(1)}</b></p>
<div class="wrap mt">${MatchEngine.lineupOf(opp.id).map(pid=>`<span class="tagchip">${Game.p(pid).nick}</span>`).join('')}</div></div>
</div>
<p class="mt small muted">Estimated win chance: <b>${Math.round(pv.pWin*100)}%</b></p>
<div class="flex mt wrap">
<button class="btn btn-accent btn-big" onclick="UI.launchSim()">🎮 Simulation Mode</button>
<button class="btn btn-primary" onclick="UI.quickSim()">⚡ Quick Sim</button>
</div>
<p class="tiny muted mt">Simulation mode: live map veto, buy decisions, round tactics & timeouts. Quick sim lets your coach handle it.</p>`;
this.modal(html,true);
},
quickSim(){
const fx=window.__fx;
const res=MatchEngine.simulate(fx,fx.evId?G.calendar.find(e=>e.id===fx.evId):null);
this.showPostMatch(res);
},
launchSim(){
this.closeModal();
SimSession.start(window.__fx,window.__fx.evId?G.calendar.find(e=>e.id===window.__fx.evId):null);
$('simOverlay').style.display='flex';
this.renderSim();
},
renderSim(){
const S=SimSession.s;if(!S)return;
const w=$('simWrap');
if(S.phase==='veto'||S.phase==='pregame'){w.innerHTML=this.simVetoHtml(S);}
else if(S.phase==='intermission'){w.innerHTML=this.simInterHtml(S);}
else if(S.phase==='done'){w.innerHTML=this.simDoneHtml(S);}
else{w.innerHTML=this.simRoundHtml(S);}
w.querySelectorAll('.sim-feed').forEach(f=>f.scrollTop=f.scrollHeight);
// static minimap preview between rounds (a live anim owns the canvas otherwise)
if(S.phase==='round'&&S.awaiting!=='anim'&&typeof VIZ!=='undefined'&&VIZ.ok&&!VIZ.R){
const c=S.cur;if(!c)return;
const ctIsA=MatchEngine.sideOf(c,'a',c.round);
const roster=c.aLineup.concat(c.bLineup).map(pid=>({pid,nick:Game.p(pid).nick,
side:c.aLineup.includes(pid)?'a':'b',role:Game.p(pid).role,dies:false,order:0,killCount:0}));
try{VIZ.preview(c.map,roster,ctIsA?'a':'b');}catch(e){}
}
},
/* ---------- 🤖 AUTO-SPECTATE: watch the whole series hands-free ---------- */
toggleAuto(){
G.ui.autoSim=!G.ui.autoSim;
this.toast(G.ui.autoSim?'🤖 Auto-spectate ON — enjoy the show':'⏸ Auto-spectate paused');
if(G.ui.autoSim){this.renderSim();this.autoTick();}
},
autoTick(){
if(!G.ui.autoSim)return;
const S=SimSession.s;if(!S){G.ui.autoSim=false;return;}
try{
if(S.phase==='done'){
G.ui.autoSim=false;
}else if(S.phase==='veto'){
if(SimSession.vetoDone())SimSession.beginMap(0);
else if(SimSession.myTurn()){
// ban/pick the best own-affinity map still in the pool
const me=Game.me();
let best=null,bv=-99;
ACTIVE_MAPS.forEach(m=>{
if(!S.veto.pool.has(m))return;
const v=me.mapAff[m]||70;
if(v>bv){bv=v;best=m;}
});
if(best)SimSession.vetoAction(best);
}
}else if(S.phase==='pregame'){
SimSession.beginMap(0);
}else if(S.phase==='intermission'){
SimSession.beginMap(S.results.length);
}else if(S.phase==='round'){
const c=S.cur;
if(S.awaiting==='buy')SimSession.chooseBuy(MatchEngine.autoBuy(c,S.userId));
else if(S.awaiting==='tactic')SimSession.chooseTactic(U.pick(['default','default','default','aggr','patient','fake']));
else if(S.awaiting==='next')SimSession.nextRound();
// awaiting 'anim' → let the animation play, just wait
}
}catch(e){}
// never rebuild the DOM while an animation owns the canvas
if(!(S.phase==='round'&&S.awaiting==='anim'))this.renderSim();
if(G.ui.autoSim)setTimeout(()=>this.autoTick(),460);
},
simVetoHtml(S){
const v=S.veto;
const done=SimSession.vetoDone();
const myTurn=!done&&SimSession.myTurn();
const isBan=S.veto.step<SimSession.totalVetoSteps();
const aiTag=Game.team(SimSession.oppId()).tag;
return `<div class="sim-scorebar"><div class="sim-team">${U.esc(Game.team(S.aId).name)}</div>
<div class="sim-round-info"><div class="rlabel">MAP VETO · BO${S.bo}</div><div class="rscore" style="font-size:20px">🗳</div></div>
<div class="sim-team right">${U.esc(Game.team(S.bId).name)}</div></div>
<div class="sim-body" style="grid-template-columns:1fr">
<div class="sim-panel">
<div class="flex spread" style="align-items:center;margin-bottom:8px">
<span class="tiny muted">MAP VETO · BO${S.bo}</span>
<button class="btn ${G.ui.autoSim?'btn-accent':'btn-ghost'} btn-sm" onclick="UI.toggleAuto()">${G.ui.autoSim?'⏸ AUTO-SPECTATE':'🤖 AUTO-SPECTATE'}</button>
</div>
${done?`<p><b class="acc">Map pool set:</b> ${v.seq.join(' → ')}</p>
<button class="btn btn-accent btn-big" onclick="SimSession.beginMap(0);UI.renderSim()">▶ Start match on ${v.seq[0]}</button>`
:myTurn?`<p><b class="acc">Your move:</b> ${isBan?'🚫 BAN':'✅ PICK'} a map.</p>`
:`<p><b>${aiTag}</b> is deciding…</p>`}
${!done?`<div class="veto-grid">${ACTIVE_MAPS.map(mp=>{
const gone=!v.pool.has(mp);
const aff=Game.me().mapAff[mp]||0;
const picked=v.seq.includes(mp);
return `<div class="map-card ${gone?'dead':''}" ${myTurn&&!gone?`onclick="SimSession.vetoAction('${mp}');UI.renderSim()"`:''}>
<b>${mp}</b><br><span class="tiny ${aff>=0?'good':'bad'}">${aff>=0?'+':''}${aff.toFixed?aff.toFixed(1):aff} aff</span>${picked?'<br><span class="cy tiny">picked</span>':''}</div>`;
}).join('')}</div>`:''}
<div class="sim-feed" style="max-height:170px">${v.log.map(l=>`<p>${l}</p>`).join('')}</div>
</div>
</div>`;
},
simInterHtml(S){
const c=S.cur;
const last=S.results[S.results.length-1];
return `<div class="sim-scorebar"><div class="sim-team">${U.esc(Game.team(S.aId).name)}</div>
<div class="sim-round-info"><div class="rlabel">SERIES</div><div class="rscore">${S.scoreA}-${S.scoreB}</div></div>
<div class="sim-team right">${U.esc(Game.team(S.bId).name)}</div></div>
<div class="sim-body" style="grid-template-columns:1fr">
<div class="sim-panel"><h3>${last.map} — final score</h3>
<p style="font-size:34px;font-weight:900" class="${last.winner===G.playerTeamId?'good':'bad'}">${last.sa}-${last.sb}</p>
<div class="sim-feed" style="max-height:240px">${c.log.map(l=>l.startsWith('🔫')?`<p class="kf">${l}</p>`:`<p>${l}</p>`).join('')}</div>
<button class="btn btn-accent btn-big mt" onclick="SimSession.beginMap(SimSession.s.results.length);UI.renderSim()">Next map: ${S.veto.seq[S.results.length]||''} →</button></div>
</div>`;
},
simDoneHtml(S){
const win=S.scoreA>S.scoreB?S.aId:S.bId;
const iWon=win===G.playerTeamId;
const mvpP=S.m.mvp?Game.p(S.m.mvp):null;
return `<div class="sim-scorebar"><div class="sim-team">${U.esc(Game.team(S.aId).name)}</div>
<div class="sim-round-info"><div class="rlabel">FINAL</div><div class="rscore">${S.scoreA}-${S.scoreB}</div></div>
<div class="sim-team right">${U.esc(Game.team(S.bId).name)}</div></div>
<div class="sim-body" style="grid-template-columns:1fr">
<div class="sim-panel"><h2>${iWon?'🏆 VICTORY!':'💀 DEFEAT'}</h2>
<p>MVP: <b class="gold">${mvpP?`${mvpP.nick}`:'—'}</b></p>
${S.results.map(r=>`<div class="kv"><span>${r.map}</span><b>${r.sa}-${r.sb}</b></div>`).join('')}
<div class="flex mt">
<button class="btn btn-accent btn-big" onclick="$('simOverlay').style.display='none';UI.showBoxFromSim()">📊 Full report</button>
</div></div></div>`;
},
showBoxFromSim(){
const S=SimSession.s;
if(S&&S.m&&S.m.winner)this.boxScoreInline(S.m,S.ev,{continueBtn:true});
},
simRoundHtml(S){
const c=S.cur,k=S.userId;
const opT=Game.team(k==='a'?S.bId:S.aId);
const myScore=k==='a'?c.sa:c.sb,opScore=k==='a'?c.sb:c.sa;
const moPos=50+(k==='a'?c.momentum:-c.momentum)*5;
const buyOpts=SimSession.buyOptions();
let controls='';
if(S.awaiting==='buy'){
controls=`<div class="eco-cash"><span>Your team funds</span><b>$${U.fmtNum(c.fund[k])}</b></div>
<div class="buy-grid">${buyOpts.map(o=>`<button class="choice" onclick="SimSession.chooseBuy('${o.id}');UI.renderSim()"><b>${o.name}</b><small>${o.desc}</small></button>`).join('')}</div>
${c.timeouts[k]>0?`<button class="btn btn-ghost btn-sm mt" onclick="if(SimSession.callTimeout())UI.renderSim()">🎙 Tactical timeout (${c.timeouts[k]} left)</button>`:''}
<p class="tiny muted mt">Tip: eco when under 6.8K, force with 6.811.8K. Mix tactics — smart opponents read patterns.</p>`;
}else if(S.awaiting==='tactic'){
controls=`<p class="small">Buy locked: <b class="acc">${String(S.userBuy).toUpperCase()}</b>. Choose your approach:</p>
<div class="tac-grid">${TACTICS.map(t=>`<button class="choice" onclick="SimSession.chooseTactic('${t.id}')"><b>${t.name}</b><small>${t.desc}</small></button>`).join('')}</div>`;
}else if(S.awaiting==='anim'){
controls=`<div class="flex spread mt"><span class="bad tiny">🔴 LIVE</span>
<button class="btn btn-ghost btn-sm" onclick="VIZ.skip()">⏩ Skip animation</button></div>`;
}else if(S.awaiting==='next'){
controls=`<button class="btn btn-accent btn-big" onclick="SimSession.nextRound();UI.renderSim()">Next round →</button>
<button class="btn btn-ghost mt" onclick="SimSession.simRest();UI.renderSim()">⏭ Simulate rest of match</button>`;
}
const ctIsA=MatchEngine.sideOf(c,'a',c.round);
const lineupChips=(k==='a'?c.aLineup:c.bLineup).map(pid=>{
const p=Game.p(pid);const b=c.box[pid];
return `<span class="tagchip">${p.nick} <b class="tiny num">${b?`${b.k}/${b.d}`:'0/0'}</b>${p.injuryDays?' 🏥':''}</span>`;
}).join('');
return `<div class="sim-round">
<div class="sim-scorebar">
<div class="sim-team"><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:${Game.team(S.aId).color}"></span>${U.esc(Game.team(S.aId).name)}</div>
<div class="sim-round-info"><div class="rlabel">${U.esc(c.map)} · Round ${c.round+1} · MR12</div>
<div class="rscore"><span style="color:${c.sa>c.sb?'#fff':'var(--muted)'}">${c.sa}</span> : <span style="color:${c.sb>c.sa?'#fff':'var(--muted)'}">${c.sb}</span></div>
<div class="momentum"><i style="left:${U.clamp(moPos,4,92)}%"></i></div>
<div class="tiny muted mt">You: ${myScore}${opT.tag}: ${opScore} · OT target ${c.winScore}</div></div>
<div class="sim-team right">${U.esc(Game.team(S.bId).name)}<span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:${Game.team(S.bId).color}"></span></div></div>
<div class="sim-map-line">${S.veto.seq.map((mp,i)=>
{const r=S.results[i];
return `<span style="${i===S.curIdx?'color:#fff;font-weight:800':''};text-decoration:${r?'line-through':'none'}">${mp}${r?` <b>${r.sa}-${r.sb}</b>`:''}</span>`;}).join(' · ')}</div>
<div class="sim-stage">
<canvas id="simCanvas" width="1280" height="640"></canvas>
<div class="stage-note tiny">${ctIsA?'Your side: <b>CT</b> — hold the sites':'Your side: <b>T</b> — execute onto a site'}</div>
</div>
<div class="sim-dock">
<div class="sim-panel">
<div class="flex spread wrap" style="align-items:center;gap:10px">
<span class="seg">
<button onclick="VIZ.setCam('follow')" title="Follow camera">🎥</button>
<button onclick="VIZ.setCam('radar')" title="Full-map radar">🗺</button>
<button onclick="UI.toast('Speed x'+VIZ.cycleSpeed())" title="Cycle speed">⏩<span id="spdLbl">${VIZ.speed}</span></button>
<button onclick="VIZ.sndOn=!VIZ.sndOn;UI.toast(VIZ.sndOn?'🔊 Sound on':'🔇 Muted')" title="Sound"><span id="sndLbl">${VIZ.sndOn?'🔊':'🔇'}</span></button>
</span>
<button class="btn ${G.ui.autoSim?'btn-accent':'btn-ghost'} btn-sm" onclick="UI.toggleAuto()">${G.ui.autoSim?'⏸ AUTO-SPECTATE':'🤖 AUTO-SPECTATE'}</button>
</div>
<h3 class="mt-0" style="margin-top:8px">${S.awaiting==='next'||S.awaiting==='anim'?'Round over':'Command the round'}</h3>${controls}
<div class="wrap mt" style="border-top:1px solid var(--line);padding-top:8px">${lineupChips}
<span class="tiny muted">🎙 ${c.timeouts[k]} timeouts left</span></div>
</div>
<div class="sim-feed dock-feed">${c.log.map(l=>l.startsWith('🔫')?`<p class="kf">${l}</p>`:`<p>${l}</p>`).join('')}</div>
</div></div>`;
},
/* ---------- post match (quick sim) ---------- */
showPostMatch(res){
const iWon=res.winner===G.playerTeamId;
const mvp=res.mvp?Game.p(res.mvp):null;
const b=res.box[res.mvp];
const html=`<h2>${iWon?'🏆 VICTORY':'💀 DEFEAT'}${U.esc(Game.team(res.winner).name)} win ${res.score}</h2>
<p class="muted small">${res.maps.map(x=>`${x.map} <b>${x.score}</b>`).join(' · ')}${res.note||''}</p>
<p class="mt">MVP: <b class="gold">${mvp?`${mvp.nick} (${(b.ratingSum/b.mapsN).toFixed(2)})`:'—'}</b></p>
<div class="flex mt wrap"><button class="btn btn-ghost" onclick="UI.closeModal();UI.boxFromQuick();">📊 Box score</button>
<button class="btn btn-primary" onclick="UI.continueAfterMatch()">Continue →</button></div>`;
this.modal(html,true);
},
boxFromQuick(){
const fx=window.__fx;
if(fx&&fx.winner)this.boxScoreInline(fx,fx.evId?G.calendar.find(e=>e.id===fx.evId):null,true);
},
/* ---------- save menu ---------- */
saveModal(){
const html=`<button class="close-x" onclick="UI.closeModal()">×</button>
<h2>Career Menu</h2>
<div class="grid mt" style="gap:10px">
<button class="btn btn-primary" onclick="Save.save(false);UI.toast('Game saved.','good');UI.closeModal()">💾 Save game</button>
<button class="btn btn-ghost" onclick="Main.exportSave()">📤 Export save to file</button>
<button class="btn btn-ghost" onclick="Main.importSave()">📥 Import save from file</button>
<button class="btn btn-danger" onclick="if(confirm('Quit to title? Progress stays in autosave.')){UI.closeModal();Main.toTitle()}">🏠 Quit to title</button>
</div>
<p class="tiny muted mt">Autosave runs every in-game week. Saves live in your browser's localStorage.</p>`;
this.modal(html);
},
/* ---------- new game wizard ---------- */
showNewGame(){
$('titleScreen').style.display='none';
$('newGameScreen').style.display='block';
Game.newWorld(document.querySelector('input[name=difficulty]:checked')?.value||'normal');
const list=$('teamPickList');
list.innerHTML=Object.values(G.teams).filter(t=>t.tier<=2).sort((a,b)=>b.tier-a.tier).map(t=>{
const best=Game.teamPlayers(t).sort((a,b)=>playerOverall(b)-playerOverall(a)).slice(0,3);
return `<div class="team-pick" onclick="UI.pickTeam('${t.id}')">
<div class="tp-name" style="color:${t.color}">${U.esc(t.name)}</div>
<div class="tp-meta"><span>${t.region} · Tier ${t.tier}</span><span>Avg ${(Game.teamPlayers(t).reduce((s,p)=>s+playerOverall(p),0)/5).toFixed(0)} OVR</span></div>
<div class="tiny muted mt">${best.map(p=>p.nick).join(' · ')}</div></div>`;
}).join('');
},
pickTeam(teamId){
const diff=document.querySelector('input[name=difficulty]:checked')?.value||'normal';
Game.newWorld(diff);
Game.startWithTeam(teamId);
this.enterGame();
},
enterGame(){
$('newGameScreen').style.display='none';
$('titleScreen').style.display='none';
this.show();
Save.save(true);
this.toast('Career started. Good luck, GM! 🎙️','good');
},
};
function newsRow(n,full){
const ACT={bid:['🔁 Review bids',"G.ui.marketTab='offers';UI.go('transfers')"],
renew:null,sponsor:['💼 Finances',"UI.go('finance')"],event:['🏆 Tournaments',"UI.go('tournaments')"]};
let act='';
if(full&&n.action){
if(n.action.kind==='renew'&&n.action.pid){
act=`<div class="mt"><button class="btn btn-accent btn-sm" onclick="UI.negotiateModal('${n.action.pid}','renew')">🖊 Renew now</button></div>`;
}else if(ACT[n.action.kind]){
act=`<div class="mt"><button class="btn btn-ghost btn-sm" onclick="${ACT[n.action.kind][1]}">${ACT[n.action.kind][0]}</button></div>`;
}
}
return `<div class="news-item ${n.read?'':'unread'}" onclick="G.news.find(x=>x.id===${n.id}).read=true;UI.updateTopbar()">
<span class="n-date">${U.fmtDateShort(n.day)}</span>
<div><div class="n-title">${U.esc(n.title)}</div>
${full?`<div class="small muted">${n.body}</div>`:''}
${act}
<span class="tiny muted">${n.tag}</span></div></div>`;
}
+101
View File
@@ -0,0 +1,101 @@
'use strict';
/* ================= util ================= */
const U = {
rnd:(a,b)=>a+Math.random()*(b-a),
ri:(a,b)=>Math.floor(a+Math.random()*(b-a+1)),
pick:a=>a[Math.floor(Math.random()*a.length)],
pickW:(items,wfn)=>{ // weighted pick
let tot=0; const ws=items.map(it=>{const w=Math.max(0.0001,wfn(it)); tot+=w; return w;});
let r=Math.random()*tot;
for(let i=0;i<items.length;i++){ r-=ws[i]; if(r<=0) return items[i]; }
return items[items.length-1];
},
chance:p=>Math.random()<p,
gauss:(m=0,s=1)=>{let u=0,v=0;while(!u)u=Math.random();while(!v)v=Math.random();return m+s*Math.sqrt(-2*Math.log(u))*Math.cos(2*Math.PI*v);},
clamp:(v,a,b)=>Math.max(a,Math.min(b,v)),
lerp:(a,b,t)=>a+(b-a)*t,
sum:arr=>arr.reduce((s,x)=>s+x,0),
avg:arr=>arr.length?U.sum(arr)/arr.length:0,
shuffle:a=>{for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[a[i],a[j]]=[a[j],a[i]];}return a;},
esc:s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])),
uid:()=> 'x'+Math.random().toString(36).slice(2,9)+Date.now().toString(36).slice(-4),
fmtM(n){
if(n===Infinity||n===-Infinity) return '∞';
const neg=n<0; n=Math.abs(Math.round(n));
let s;
if(n>=1e9) s=(n/1e9).toFixed(2)+'B';
else if(n>=1e6) s=(n/1e6).toFixed(n>=1e7?1:2)+'M';
else if(n>=1e3) s=(n/1e3).toFixed(0)+'K';
else s=String(n);
return (neg?'-$':'$')+s;
},
fmtNum(n){return Math.round(n).toLocaleString('en-US');},
/* day 0 = Mon 5 Jan 2026 */
dateFromDay(d){const dt=new Date(Date.UTC(2026,0,5)); dt.setUTCDate(dt.getUTCDate()+d); return dt;},
fmtDate(d){const dt=U.dateFromDay(d);
return dt.toLocaleDateString('en-GB',{day:'numeric',month:'short',year:'numeric',timeZone:'UTC'});},
fmtDateShort(d){const dt=U.dateFromDay(d);return dt.toLocaleDateString('en-GB',{day:'numeric',month:'short',timeZone:'UTC'});},
dow(d){return U.dateFromDay(d).getUTCDay();}, // 0 sun .. 6 sat
weekOfYear(d){return Math.floor((d+4)/7);},
logistic(x){return 1/(1+Math.exp(-x));},
};
/* role weights for overall rating (attrs: aim,pos,clutch,util,iq,move,comm) */
const ROLE_W={
IGL: {iq:.32,comm:.22,util:.14,pos:.12,aim:.08,clutch:.04,move:.08},
AWP: {aim:.38,pos:.18,iq:.13,clutch:.10,move:.10,comm:.07,util:.04},
Entry: {aim:.33,move:.20,pos:.12,clutch:.09,iq:.10,util:.08,comm:.08},
Rifler:{aim:.26,iq:.16,pos:.16,move:.12,clutch:.10,util:.10,comm:.10},
Support:{util:.28,iq:.20,comm:.17,pos:.15,aim:.10,move:.06,clutch:.04},
Lurker:{pos:.26,iq:.22,aim:.16,clutch:.14,move:.10,util:.06,comm:.06},
};
const ROLES=Object.keys(ROLE_W);
function playerOverall(p){
const w=ROLE_W[p.role]||ROLE_W.Rifler; let s=0;
for(const k in w) s+=p.attrs[k]*w[k];
return s;
}
/* market value & wage model. attrs 40..99 scale */
function playerValue(p,G){
const ovr=playerOverall(p), potGap=p.potential-ovr;
const ageF = p.age<=19?1.45 : p.age<=22?1.35 : p.age<=25?1.2 : p.age<=28?1.0 : p.age<=31?0.72 : 0.42;
let v = Math.pow(Math.max(ovr-40,1),2.75) * 14 * ageF;
v *= (1 + Math.max(potGap,0)*0.022);
v *= (0.75 + p.form*0.05); // form 0..10
v *= (0.85 + p.morale/100*0.3);
const yrsLeft = Math.max(0,(p.contractUntil-G.day)/364);
if(yrsLeft<0.5 && G.players[p.teamId]!==undefined) v*=0.65; // expiring contract discount
if(p.injuryDays>7) v*=0.85;
return Math.round(v/1000)*1000;
}
function wageDemand(p,G){
const ovr=playerOverall(p);
let base = Math.pow(Math.max(ovr-40,1),2.6)*0.75; // monthly usd
base *= (0.8 + p.form*0.045);
base *= (1 + (p.potential-ovr)*0.008);
const yrsLeft=Math.max(0,(p.contractUntil-G.day)/364);
if(yrsLeft<0.5) base*=1.25;
base *= G.diff.wageMul;
return Math.round(base/500)*500;
}
const NATIONS={ // flag emoji
UA:'🇺🇦',RU:'🇷🇺',DK:'🇩🇰',LV:'🇱🇻',IL:'🇮🇱',DE:'🇩🇪',FR:'🇫🇷',GB:'🇬🇧',PL:'🇵🇱',CZ:'🇨🇿',
SE:'🇸🇪',NO:'🇳🇴',FI:'🇫🇮',EE:'🇪🇪',LT:'🇱🇹',NL:'🇳🇱',BG:'🇧🇬',RO:'🇷🇴',RS:'🇷🇸',
US:'🇺🇸',CA:'🇨🇦',BR:'🇧🇷',AR:'🇦🇷',UY:'🇺🇾',MX:'🇲🇽',
TR:'🇹🇷',KZ:'🇰🇿',MN:'🇲🇳',CN:'🇨🇳',KR:'🇰🇷',JP:'🇯🇵',
AU:'🇦🇺',NZ:'🇳🇿',ES:'🇪🇸',PT:'🇵🇹',IT:'🇮🇹',BA:'🇧🇦',MK:'🇲🇰',GR:'🇬🇷',SA:'🇸🇦',MA:'🇲🇦',ID:'🇮🇩'
};
const FLAG=n=>NATIONS[n]||'🏳️';
function faceFor(m){ // morale emoji
if(m>=80)return '😄'; if(m>=60)return '🙂'; if(m>=40)return '😐'; if(m>=20)return '🙁'; return '😡';
}
function formStars(f){ // f 0..10 → stars display
const s=Math.round(f/2); return '★'.repeat(s)+'☆'.repeat(5-s);
}
function ovrClass(o){return o>=90?'s90':o>=80?'s80':'';}
+386
View File
@@ -0,0 +1,386 @@
/* ============================================================
VIZ — canvas renderer + host loop for LIVE tactical rounds
Simulation logic lives in live.js; this file draws the world,
plays synthesized audio, and manages cameras/speed.
============================================================ */
'use strict';
const VIZ={
ok:false,
R:null, // {st, cv, ctx, onDone, lastTs}
cam:'follow',
speed:1,
sndOn:true,
snd:null,
init(){try{const c=document.createElement('canvas');this.ok=!!(c.getContext&&c.getContext('2d'));}catch(e){this.ok=false;}return this.ok;},
setCam(m){this.cam=m;},
cycleSpeed(){this.speed=this.speed===1?2:this.speed===2?3:1;return this.speed;},
/* ---------- synthesized audio ---------- */
ac(){
if(!this.sndOn)return null;
if(!this.snd){try{this.snd=new (window.AudioContext||window.webkitAudioContext)();}catch(e){return null;}}
if(this.snd.state==='suspended')this.snd.resume();
return this.snd;
},
_noise(dur,freq,q,vol,type){
type=type||'bandpass';
const ac=this.ac();if(!ac)return;
const n=Math.max(64,Math.floor(ac.sampleRate*dur));
const buf=ac.createBuffer(1,n,ac.sampleRate),d=buf.getChannelData(0);
for(let i=0;i<n;i++)d[i]=(Math.random()*2-1)*(1-i/n);
const src=ac.createBufferSource();src.buffer=buf;
const f=ac.createBiquadFilter();f.type=type;f.frequency.value=freq;f.Q.value=q;
const g=ac.createGain();
g.gain.setValueAtTime(vol,ac.currentTime);
g.gain.exponentialRampToValueAtTime(0.001,ac.currentTime+dur);
src.connect(f);f.connect(g);g.connect(ac.destination);
src.start();
},
tone(freq,dur,vol,type,slide){
vol=vol||0.12;type=type||'square';
const ac=this.ac();if(!ac)return;
const o=ac.createOscillator(),g=ac.createGain();
o.type=type;o.frequency.setValueAtTime(freq,ac.currentTime);
if(slide)o.frequency.exponentialRampToValueAtTime(slide,ac.currentTime+dur);
g.gain.setValueAtTime(vol,ac.currentTime);
g.gain.exponentialRampToValueAtTime(0.001,ac.currentTime+dur);
o.connect(g);g.connect(ac.destination);o.start();o.stop(ac.currentTime+dur);
},
sShot(big){if(this.sndOn)this._noise(big?0.15:0.08,big?850:1700,0.8,0.3);},
sHit(){if(this.sndOn)this.tone(300,0.05,0.09,'square');},
sDeath(){if(this.sndOn)this.tone(90,0.13,0.22,'sine',50);},
sPlant(){if(this.sndOn){this.tone(1250,0.06,0.1);setTimeout(()=>this.tone(1250,0.06,0.1),95);}},
sBoom(){if(this.sndOn)this._noise(0.6,210,0.6,0.45,'lowpass');},
sFlash(){if(this.sndOn)this.tone(2400,0.25,0.12,'sine',3400);},
sBeep(fast){if(this.sndOn)this.tone(fast?1600:1150,0.05,0.08);},
sWinSting(good){
if(!this.sndOn)return;
(good?[520,660,880]:[420,330,247]).forEach((f,i)=>setTimeout(()=>this.tone(f,0.16,0.13,'triangle'),i*110));
},
/* ---------- host loop ---------- */
timescale(){return this.speed===1?7:this.speed===2?14:21;},
play(cfg,onDone){
if(!this.ok||typeof LIVE==='undefined'){onDone(null);return;}
this.stop();
const cv=document.getElementById('simCanvas');
if(!cv){onDone(null);return;}
let st;
try{st=LIVE.makeRound(cfg);}
catch(e){console.warn('live makeRound failed',e);onDone(null);return;}
this.R={st,cv,ctx:cv.getContext('2d'),onDone,lastTs:performance.now(),_nextBeep:0};
const loop=(ts)=>{
const R=this.R;if(!R)return;
let dt=(ts-R.lastTs)/1000;R.lastTs=ts;
dt=Math.min(dt,0.06);
const gdt=dt*this.timescale();
let remain=gdt;
while(remain>0&&!R.st.ended){
LIVE.step(R.st,Math.min(remain,0.05));
remain-=0.05;
}
if(R.st.ended)R.st.endT+=gdt;
this.draw(R.st);
if(R.st.planted&&!R.st.ended){
const iv=R.st.bombT<10?260:520;
if(ts>R._nextBeep){R._nextBeep=ts+iv;this.sBeep(R.st.bombT<10);}
}
if(R.st.ended&&R.st.endT>=1.15){
const st=R.st;this.stop();
try{onDone(st);}catch(e){console.warn('viz onDone error',e);}
return;
}
R.raf=requestAnimationFrame(loop);
};
this.R.raf=requestAnimationFrame(loop);
},
skip(){
const R=this.R;if(!R)return;
if(R.raf)cancelAnimationFrame(R.raf);
let guard=0;
while(!R.st.ended&&guard++<6000)LIVE.step(R.st,0.05);
this.draw(R.st);
setTimeout(()=>{if(this.R===R){const cb=R.onDone,st=R.st;this.stop();cb(st);}},120);
},
stop(){if(this.R&&this.R.raf)cancelAnimationFrame(this.R.raf);this.R=null;},
/* ---------- camera ---------- */
camFor(st,W,H){
let scale=1,ox=0,oy=0;
if(this.cam==='follow'){
const alive=st.agents.filter(a=>!a.dead);
const cx=alive.reduce((s,a)=>s+a.x,0)/Math.max(alive.length,1);
const cy=alive.reduce((s,a)=>s+a.y,0)/Math.max(alive.length,1);
scale=2.1;
ox=W/2-this._sx(st.L,U.clamp(cx,24,st.L.w-24),W,H)*scale;
oy=H/2-this._sy(st.L,U.clamp(cy,18,st.L.h-18),W,H)*scale;
ox=U.clamp(ox,W-W*scale+6,6);oy=U.clamp(oy,H-H*scale+6,6);
}
return{scale:scale,ox:ox,oy:oy};
},
_sx(L,x,W,H){return 8+x/L.w*(W-16);},
_sy(L,y,W,H){return 8+y/L.h*(H-16);},
/* ---------- main draw ---------- */
draw(st){
const R=this.R;if(!R)return;
const ctx=R.ctx,cv=R.cv,W=cv.width,H=cv.height,L=st.L,P=L.pal;
ctx.fillStyle='#0a0f18';ctx.fillRect(0,0,W,H);
const cm=this.camFor(st,W,H),scale=cm.scale;
ctx.save();
ctx.translate(cm.ox,cm.oy);ctx.scale(scale,scale);
if(st.shake>0.05)ctx.translate((Math.random()-0.5)*st.shake*2,(Math.random()-0.5)*st.shake*2);
const SX=x=>this._sx(L,x,W,H),SY=y=>this._sy(L,y,W,H);
// ground
ctx.fillStyle='#0d1420';
ctx.fillRect(-200,-200,W/scale+400,H/scale+400);
// walkable floors
ctx.fillStyle=P.floor;
L.floors.forEach(function(f){
const x=f[0],y=f[1],w=f[2],h=f[3];
ctx.fillRect(SX(x),SY(y),SX(x+w)-SX(x),SY(y+h)-SY(y));
});
ctx.strokeStyle='rgba(255,255,255,0.03)';ctx.lineWidth=0.4;
for(let gx=0;gx<=L.w;gx+=10){ctx.beginPath();ctx.moveTo(SX(gx),SY(0));ctx.lineTo(SX(gx),SY(L.h));ctx.stroke();}
for(let gy=0;gy<=L.h;gy+=10){ctx.beginPath();ctx.moveTo(SX(0),SY(gy));ctx.lineTo(SX(L.w),SY(gy));ctx.stroke();}
// site zones
['A','B'].forEach(function(k){
const s=L.sites[k];
ctx.strokeStyle='rgba(255,150,60,0.3)';ctx.lineWidth=1;
ctx.strokeRect(SX(s[0]-12),SY(s[1]-7),SX(s[0]+12)-SX(s[0]-12),SY(s[1]+7)-SY(s[1]-7));
ctx.fillStyle='rgba(255,170,80,0.22)';
ctx.font='bold 11px system-ui';ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(k,SX(s[0]),SY(s[1]));
});
// crates
(L.crates||[]).forEach(function(c){
const cx=c[0],cy=c[1],s=4;
ctx.fillStyle=P.crate;
ctx.fillRect(SX(cx-s/2),SY(cy-s/2),SX(cx+s/2)-SX(cx-s/2),SY(cy+s/2)-SY(cy-s/2));
ctx.strokeStyle='rgba(0,0,0,0.45)';
ctx.strokeRect(SX(cx-s/2),SY(cy-s/2),SX(cx+s/2)-SX(cx-s/2),SY(cy+s/2)-SY(cy-s/2));
});
// fires
st.fires.forEach(function(f){
const flick=0.75+0.25*Math.sin(performance.now()/55+f.x*7);
const x=SX(f.x),y=SY(f.y);
const r=(f.r*flick)/scale*9;
const g=ctx.createRadialGradient(x,y,0,x,y,r);
g.addColorStop(0,'rgba(255,150,40,0.85)');g.addColorStop(1,'rgba(255,60,10,0)');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(x,y,r,0,7);ctx.fill();
});
// smokes
st.smokes.forEach(function(s){
const grow=Math.min(s.life*5,1);
const fade=s.life>0.75?(1-(s.life-0.75)/0.25):1;
const x=SX(s.x),y=SY(s.y),r=s.r/scale*9*Math.max(grow,0.3);
const g=ctx.createRadialGradient(x,y,r*0.25,x,y,r);
g.addColorStop(0,'rgba(185,195,210,'+(0.85*fade)+')');
g.addColorStop(1,'rgba(185,195,210,'+(0.28*fade)+')');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(x,y,r,0,7);ctx.fill();
});
// nades in flight
st.nades.forEach(function(n){
ctx.fillStyle='rgba(0,0,0,0.35)';
ctx.beginPath();ctx.ellipse(SX(n.x),SY(n.y),1.3,0.7,0,0,7);ctx.fill();
ctx.fillStyle=n.type==='smoke'?'#9aa7b8':n.type==='flash'?'#f5f5da':'#5c8a4a';
ctx.beginPath();ctx.arc(SX(n.x),SY(n.y)-n.z*1.6,1.5,0,7);ctx.fill();
});
// corpses & pools
st.corpses.forEach(function(c){
c.grow=Math.min((c.grow||0)+0.01,1);
ctx.fillStyle='rgba(140,20,25,'+(0.55*c.grow)+')';
ctx.beginPath();ctx.ellipse(SX(c.x),SY(c.y),5*c.grow,3.6*c.grow,c.ang,0,7);ctx.fill();
ctx.save();ctx.translate(SX(c.x),SY(c.y));ctx.rotate(c.ang);
ctx.globalAlpha=0.9;
ctx.fillStyle=c.ct?'#3c5a7d':'#7d6437';
ctx.fillRect(-3.4,-1.6,6.8,3.2);
ctx.beginPath();ctx.arc(3.2,0,1.5,0,7);
ctx.fillStyle=c.ct?'#5b83ad':'#a3834c';ctx.fill();
ctx.restore();ctx.globalAlpha=1;
});
// agents
st.agents.forEach(a=>{
if(a.dead)return;
const x=SX(a.x),y=SY(a.y);
const bob=a.moving?Math.sin(performance.now()/65+a.bob)*0.4:0;
ctx.save();ctx.translate(x,y+bob*0.3);
ctx.fillStyle='rgba(0,0,0,0.38)';
ctx.beginPath();ctx.ellipse(0.7,1.5,3.2,2.1,0,0,7);ctx.fill();
ctx.rotate(a.ang);
ctx.fillStyle='#10151f';
ctx.fillRect(2.1,-0.5,a.role==='AWP'?5.2:3.6,1.05);
ctx.fillStyle=a.isCT?'#4d7fb5':'#c79a4b';
ctx.beginPath();ctx.ellipse(0,0,2.7,2,0,0,7);ctx.fill();
ctx.strokeStyle='rgba(0,0,0,0.55)';ctx.lineWidth=0.5;ctx.stroke();
ctx.fillStyle=a.isCT?'#8db8e4':'#e8c887';
ctx.beginPath();ctx.arc(0.4,0,1.25,0,7);ctx.fill();
ctx.restore();
if(a.blind>0){
ctx.strokeStyle='rgba(255,255,240,'+Math.min(a.blind,1)+')';ctx.lineWidth=1;
ctx.beginPath();ctx.arc(x,y,4.8,0,7);ctx.stroke();
}
ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(x-6,y-8.4,12,1.9);
ctx.fillStyle=a.hp>60?'#43d17c':a.hp>30?'#e6b23c':'#e05252';
ctx.fillRect(x-6,y-8.4,12*U.clamp(a.hp/100,0,1),1.9);
ctx.font='7px system-ui';ctx.textAlign='center';ctx.textBaseline='top';
ctx.fillStyle='rgba(220,230,245,0.88)';
ctx.fillText(a.nick.slice(0,9),x,y+5.2);
if(!st.planted&&st.bombCarrier===a&&st.plantP>0)VIZ.bar(ctx,x,y-11,st.plantP,'#ffab4d');
if(st.defuser===a&&st.defuseP>0)VIZ.bar(ctx,x,y-11,st.defuseP,'#63a7e6');
if(st.bombCarrier===a&&!st.planted){
ctx.font='bold 7px system-ui';ctx.fillStyle='#ffab4d';
ctx.fillText('C4',x,y-13.8);
}
});
// planted bomb
if(st.planted){
const bx=SX(st.bombPos.x),by=SY(st.bombPos.y);
const iv=st.bombT<10?130:320;
const pulse=0.5+0.5*Math.sin(performance.now()/iv);
ctx.strokeStyle='rgba(255,130,40,'+(0.4+pulse*0.55)+')';ctx.lineWidth=1.3;
ctx.beginPath();ctx.arc(bx,by,5+pulse*4,0,7);ctx.stroke();
ctx.fillStyle='#ff8c28';ctx.fillRect(bx-2.3,by-2.3,4.6,4.6);
if(st.defuseP>0){
ctx.strokeStyle='#63a7e6';ctx.lineWidth=1.2;
ctx.beginPath();ctx.arc(bx,by,8,-Math.PI/2,-Math.PI/2+U.clamp(st.defuseP,0,1)*Math.PI*2);ctx.stroke();
}
}
// transient fx
st.fx.forEach(f=>{
const p=U.clamp((st.t-f.t0)/f.dur,0,1);
if(f.type==='tracer'){
ctx.strokeStyle='rgba(255,235,170,'+(1-p)+')';ctx.lineWidth=0.8;
ctx.beginPath();ctx.moveTo(SX(f.x1),SY(f.y1));ctx.lineTo(SX(f.x2),SY(f.y2));ctx.stroke();
}else if(f.type==='muzzle'){
ctx.fillStyle='rgba(255,230,150,'+(1-p)+')';
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),1.5-p,0,7);ctx.fill();
}else if(f.type==='bloodHit'){
ctx.fillStyle='rgba(190,40,40,'+(1-p)+')';
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),1.6+p,0,7);ctx.fill();
}else if(f.type==='spark'){
ctx.fillStyle='rgba(255,220,140,'+(1-p)+')';
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),1+p*1.6,0,7);ctx.fill();
}else if(f.type==='puff'){
ctx.fillStyle='rgba(180,180,180,'+((1-p)*0.5)+')';
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),1+p*2.2,0,7);ctx.fill();
}else if(f.type==='boom'){
const r=(3+p*13)/scale*5;
ctx.strokeStyle='rgba(255,180,80,'+(1-p)+')';ctx.lineWidth=1.4;
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),r,0,7);ctx.stroke();
ctx.fillStyle='rgba(255,200,90,'+((1-p)*0.45)+')';
ctx.beginPath();ctx.arc(SX(f.x),SY(f.y),r*0.6,0,7);ctx.fill();
}else if(f.type==='flashPop'){
ctx.fillStyle='rgba(255,255,255,'+((1-p)*0.92)+')';
ctx.fillRect(0,0,W,H);
}
});
ctx.restore();
/* ===== screen-space overlays ===== */
if(st.pain>0.02){
const g=ctx.createRadialGradient(W/2,H/2,H*0.32,W/2,H/2,H*0.72);
g.addColorStop(0,'rgba(255,30,30,0)');
g.addColorStop(1,'rgba(255,25,25,'+(st.pain*0.36)+')');
ctx.fillStyle=g;ctx.fillRect(0,0,W,H);
}
// HUD top bar
ctx.fillStyle='rgba(8,12,20,0.78)';ctx.fillRect(0,0,W,26);
const aA=st.agents.filter(a=>!a.dead&&a.side==='a').length;
const aB=st.agents.filter(a=>!a.dead&&a.side==='b').length;
for(let i=0;i<5;i++){
ctx.fillStyle=i<aA?'#4d7fb5':'rgba(90,100,120,0.25)';
ctx.fillRect(14+i*11,8,8,10);
ctx.fillStyle=i<aB?'#c79a4b':'rgba(90,100,120,0.25)';
ctx.fillRect(W-22-i*11,8,8,10);
}
const shown=st.planted?st.bombT:st.timeLeft;
ctx.fillStyle=st.planted?'#ffab4d':shown<15?'#ff7a5c':'#dfe8f5';
ctx.font='bold 13px ui-monospace,monospace';ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(Math.floor(Math.max(shown,0)/60)+':'+String(Math.floor(Math.max(shown,0)%60)).padStart(2,'0'),W/2,13);
// kill feed
ctx.textAlign='right';ctx.textBaseline='middle';
st.feed.forEach((f,i)=>{
const age=st.t-f.t0;
const slide=(1-Math.min(age/0.05,1))*150;
const alpha=age>3.2?Math.max(1-(age-3.2)/0.6,0):1;
const y=40+i*19;
ctx.font='bold 10px system-ui';
const kw=ctx.measureText(f.killer).width;
ctx.font='9px ui-monospace,monospace';
const ww=ctx.measureText(' '+f.wpn+' ').width;
ctx.font='bold 10px system-ui';
const vw=ctx.measureText(f.victim).width;
const bw=kw+ww+vw+18;
ctx.fillStyle='rgba(8,12,20,'+(0.72*alpha)+')';
ctx.beginPath();ctx.roundRect(W-bw-slide,y-8,bw,16,4);ctx.fill();
ctx.globalAlpha=alpha;
ctx.fillStyle=f.kCT?'#8db8e4':'#e8c887';
ctx.fillText(f.killer,W-vw-ww-9-slide,y);
ctx.font='9px ui-monospace,monospace';ctx.fillStyle='rgba(200,210,225,0.85)';
ctx.fillText(' '+f.wpn+' ',W-vw-9-slide,y);
ctx.font='bold 10px system-ui';
ctx.fillStyle=f.vCT?'#8db8e4':'#e8c887';
ctx.fillText(f.victim,W-9-slide,y);
ctx.globalAlpha=1;
});
// callout
if(st.callout!==undefined&&st.callout!==null){
const p=(st.t-st.calloutT)/0.55;
if(p<1){
const pop=p<0.2?(0.2-p)*5:0;
const alpha=p>0.65?Math.max(1-(p-0.65)/0.35,0):1;
ctx.save();ctx.globalAlpha=Math.max(alpha,0);
ctx.translate(W/2,62-pop*6);ctx.scale(1+pop,1+pop);
ctx.font='italic bold 21px system-ui';ctx.textAlign='center';ctx.textBaseline='middle';
ctx.lineWidth=4;ctx.strokeStyle='rgba(5,8,14,0.85)';
ctx.strokeText(st.callout.txt,0,0);
ctx.fillStyle=st.callout.color||'#fff';
ctx.fillText(st.callout.txt,0,0);
ctx.restore();
}else st.callout=null;
}
// end banner
if(st.ended){
const reasonTxt={elim:'ELIMINATION',bomb:'THE BOMB DETONATED 💥',
defuse:'BOMB DEFUSED',time:'TIME UP — DEFENSE HOLDS'}[st.reason]||'';
const wt=st.cfg.tags[st.winner]||'?';
ctx.fillStyle='rgba(6,10,18,0.74)';ctx.fillRect(0,H/2-34,W,68);
ctx.fillStyle=st.cfg.colors[st.winner]||'#fff';ctx.font='bold 20px system-ui';
ctx.textAlign='center';ctx.textBaseline='middle';
ctx.fillText(wt+' WIN THE ROUND',W/2,H/2-9);
ctx.fillStyle='rgba(255,255,255,0.8)';ctx.font='12px system-ui';
ctx.fillText(reasonTxt+' · '+st.cfg.scoreAfter[0]+' : '+st.cfg.scoreAfter[1],W/2,H/2+14);
}
},
bar(ctx,x,y,p,color){
ctx.fillStyle='rgba(0,0,0,0.6)';ctx.fillRect(x-8,y-1.5,16,2.6);
ctx.fillStyle=color;ctx.fillRect(x-8,y-1.5,16*U.clamp(p,0,1),2.6);
},
/* static pre-round preview */
preview(map,roster,ctSide){
if(!this.ok||this.R)return;
const cv=document.getElementById('simCanvas');if(!cv)return;
const fake={map:map,ctSide:ctSide,lineups:{a:[],b:[]},players:{},skills:{},
buys:{a:'full',b:'full'},tactic:{a:'default',b:'default'},
tags:{},colors:{},userSide:'a',scoreAfter:[0,0]};
roster.forEach(r=>{
fake.players[r.pid]={nick:r.nick,role:r.role};
fake.skills[r.pid]={aim:60,spd:60,clutch:60};
if(r.side===ctSide)fake.lineups.a.push(r.pid);
else fake.lineups.b.push(r.pid);
});
try{
const st=LIVE.makeRound(fake);
const keep=this.R;this.R={st:st,cv:cv,ctx:cv.getContext('2d')};
this.draw(st);this.R=keep;
}catch(e){}
},
};
if(typeof window!=='undefined')VIZ.init();