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:
+297
@@ -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
File diff suppressed because it is too large
Load Diff
+708
@@ -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
@@ -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
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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.8–11.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
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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':'';}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user