From 4c7fe1db4c836e80e8e5241c4cdc01d0d14089c5 Mon Sep 17 00:00:00 2001 From: deepseek Date: Sun, 23 Aug 2026 07:00:47 +0000 Subject: [PATCH] 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) --- .gitignore | 3 + README.md | 99 +++++ css/style.css | 192 +++++++++ index.html | 111 +++++ js/data.js | 297 +++++++++++++ js/game.js | 1030 ++++++++++++++++++++++++++++++++++++++++++++++ js/live.js | 708 +++++++++++++++++++++++++++++++ js/main.js | 77 ++++ js/match.js | 603 +++++++++++++++++++++++++++ js/ui.js | 995 ++++++++++++++++++++++++++++++++++++++++++++ js/util.js | 101 +++++ js/viz.js | 386 +++++++++++++++++ test/debug_ev.js | 23 ++ test/ratings.js | 21 + test/smoke.js | 76 ++++ 15 files changed, 4722 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 css/style.css create mode 100644 index.html create mode 100644 js/data.js create mode 100644 js/game.js create mode 100644 js/live.js create mode 100644 js/main.js create mode 100644 js/match.js create mode 100644 js/ui.js create mode 100644 js/util.js create mode 100644 js/viz.js create mode 100644 test/debug_ev.js create mode 100644 test/ratings.js create mode 100644 test/smoke.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4834b54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.log +node_modules/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..f679def --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# 🎯 CS Manager 2026 + +A deep, single-player **Counter-Strike esports organization manager** — inspired by games like Esports Manager 2026. No story mode, no game-over screen: just you, your roster, and the long road to legend. + +![genre](https://img.shields.io/badge/genre-management%2Fsimulation-orange) ![platform](https://img.shields.io/badge/platform-browser-blue) ![deps](https://img.shields.io/badge/dependencies-zero-brightgreen) + +## ▶ Play + +Open `index.html` in any modern browser — or serve the folder: + +```bash +cd counterstrikemanager +python3 -m http.server 8080 +# → http://localhost:8080 +``` + +No build step, no dependencies, no network required. + +## 🕹 Features + +### Career +- **Take over a real organization** (NAVI, Vitality, Spirit, FaZe, G2, Falcons, The MongolZ, MOUZ, Astralis, Liquid + tier-2/3 clubs) with authentic 2026-era rosters and roles… +- **…or found your own org** and draft five semi-pro prospects. Legend-tier start. +- Three difficulties (Rookie / Pro / Legend) affecting budget, wages and rival aggression. +- **Sandbox forever**: season rollovers, aging players, no forced ending. + +### Simulation Mode (the showpiece) +When your match day arrives, step into the server: +1. **Interactive map veto** — ban & pick against an AI that knows its own map pool. +2. **LIVE tactical simulation** — rounds are no longer pre-scripted theater: a real-time micro-engine fights them out on top-down renditions of Dust II, Mirage, Inferno, Nuke, Ancient and more. + - **A* pathfinding** around genuine map geometry — walls, doors, corridors, crates as cover + - **Raycast ballistics**: spread that grows with movement and range, damage falloff, armor, headshots (⌖), wall sparks and smoke puffs on misses + - **Utility that matters**: smokes physically block line of sight, flashes blind anyone facing them, HE deals radial damage; executes are scheduled by your tactic (fakes smoke the other site first) + - **Real roles & brains**: CTs hold angles and rotate on contact, attackers group, fake, execute onto a site, plant (3.2 s, interrupted by pressure), post-plant positioning, CT retakes with defuse (9 s) + - The round result — winner, every kill with weapon, damage per player, clutches — comes OUT of the firefight and is fed back into your career economy and box score + - Synthesized SFX (gunfire, AWP boom, plant beeps that accelerate, C4 explosion, win stings), CS2-style kill feed, FIRST BLOOD / CLUTCH callouts, HP bars over agents +3. **Watch it your way** — 🎥 follow-cam or 🗺 full radar, ⏩ speed x1/x2/x3, per-round Skip, Simulate-rest, or full **🤖 AUTO-SPECTATE**: one button plays the entire series hands-free (auto veto by affinity, auto buys/tactics) while you just watch. +4. **MR12 round-by-round play** — call every buy (full / force / eco), pick tactics (fast execute, patient default, fakes, site stacks), burn tactical timeouts to break opponent momentum. +5. **A living economy** — loss bonuses, save rounds, pistol rounds, OT at 12-12 (first to 16, 19…). +6. **Clutch moments** — 1vX attempts decided by player clutch attribute; kill-feed events narrate every round. +7. Smart opponents **read your tactic patterns** via their analysts — mix it up. + +Prefer to delegate? **⚡ Quick Sim** lets your coach play out any match instantly with full box scores (K/D/ADR/rating, MVP). + +### Squad & Development +- Seven attributes per player (aim, positioning, clutch, utility, gamesense, movement, communication) weighted by role — IGLs are built differently from AWPers. +- Form, morale, motivation, fatigue, injuries, hidden potential, personality traits. +- **Weekly training plans** per player: focus (aim/utility/tactics/teamwork/movement/rest/media) × intensity. Hard work grows skills but piles fatigue → overuse injuries. +- Bootcamps for chemistry, academy graduates when you run dry. + +### Talk Module +One meaningful conversation per ~10 days per player: praise, motivate, discuss role, demand professionalism, hint at a raise — or make a **starting-spot promise** you'd better keep. Outcomes depend on personality and tone. + +### Squad Building +- **Youth scouting camps** ($80K): scout three generated prospects (potential scales with club reputation), sign one to a three-year academy deal. +- Academy auto-promotion keeps every squad fieldable; rosters cap at 8. +- **Career milestones**: 10 unlockable achievements (first win, trophies, TOP 10 / #1 world, $1M bank, superstar signing, academy graduate, Grand Slam…) with inbox notifications and a dashboard tracker. + +### Transfers & Contracts +- Free agency, transfer listings with asking prices, AI-to-AI moves, incoming bids for your stars (they can expire!), wage negotiations with signing bonuses and rejection counters, releases with severance, expiring-contract drama. +- **Two transfer windows per season** (spring day ~150-230, winter from ~300) gate all AI transfer activity; the Transfer Center shows live window status. +- **Development loans**: send young bench players to lower clubs for 90 days of playing time and faster growth. +- Market values react to overall rating, potential, age curve, form, morale and contract length. +- Weekly training reports land in your inbox so development is never invisible. + +### Tournaments & Rankings +- A full annual circuit: BLAST Bounty, IEM Katowice, EPL, IEM Melbourne/Dallas/Cologne/Chengdu, **the Budapest Major**, Esports World Cup, Thunderpick, Elisa Masters, CCT and the BLAST World Final — with prize money, entry cutoffs by world ranking and wildcard lotteries. +- Live brackets progress day by day even when you're not playing. +- World ranking points (placement-weighted, decaying), movement arrows, season player leaderboard. +- 💎 **Grand Slam**: win 4 elite events consecutively within a year for a $1M bonus. + +### Business +- Weekly wages, ops costs, merch/streaming income scaled by fans, monthly sponsor payouts. +- Sponsorship offers arrive based on ranking/reputation — three partner slots. +- Board confidence reacts to **your own recent form vs expectations** and financial health (warnings only — no firing screen, this is sandbox). +- Dashboard shows live **season objectives** (ranking target, finances, board) with progress bars, and Rankings keeps your season-by-season history. + +## 💾 Saving +Autosaves weekly to `localStorage`, manual save anytime, plus JSON export/import for backing up or sharing careers. + +## ⌨️ Shortcuts +- Space/Enter — advance one week +- Esc — close modal + +## 🗂 Project layout +``` +index.html shell & screens +css/style.css dark esports theme +js/util.js RNG/format helpers, ratings, value model +js/data.js teams, players, staff pool, maps, 2026 calendar +js/game.js world sim: time, finances, training, transfers, events +js/match.js round engine + quick-sim + interactive SimSession +js/ui.js screens, modals, simulation overlay rendering +js/main.js boot, save/export/import, shortcuts +test/ headless engine smoke tests (node) +``` + +## ✍️ Notes +Rosters approximate the 2025–26 scene; attributes are hand-tuned estimates for gameplay balance, not official data. diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..26f9ed0 --- /dev/null +++ b/css/style.css @@ -0,0 +1,192 @@ +/* ============ CS MANAGER 2026 — theme ============ */ +:root{ + --bg:#0a0e17; --bg2:#0e1422; --panel:#131a2b; --panel2:#182136; --line:#232d45; + --txt:#dbe4f5; --muted:#8493b0; --dim:#54617e; + --accent:#ff7a1a; --accent2:#38bdf8; --good:#34d399; --bad:#f87171; --warn:#fbbf24; --gold:#fcd34d; + --rad:10px; +} +*{box-sizing:border-box;margin:0;padding:0} +html,body{height:100%} +body{background:var(--bg);color:var(--txt);font:14px/1.45 "Inter","Segoe UI",system-ui,-apple-system,sans-serif;overflow-x:hidden} +button{font:inherit;color:inherit;background:none;border:none;cursor:pointer} +input,select{font:inherit;color:var(--txt);background:var(--bg2);border:1px solid var(--line);border-radius:6px;padding:6px 8px} +input:focus,select:focus{outline:1px solid var(--accent)} +h1,h2,h3{font-weight:800;letter-spacing:.02em} +h2{margin-bottom:6px} h3{margin:14px 0 8px} +.muted{color:var(--muted)} .small{font-size:12px} .tiny{font-size:11px} +.good{color:var(--good)} .bad{color:var(--bad)} .warn{color:var(--warn)} .gold{color:var(--gold)} .acc{color:var(--accent)} .cy{color:var(--accent2)} +.mt{margin-top:12px}.mb{margin-bottom:12px} +.flex{display:flex;gap:10px;align-items:center}.wrap{flex-wrap:wrap}.spread{justify-content:space-between} +.grid{display:grid;gap:12px} +::-webkit-scrollbar{width:9px;height:9px}::-webkit-scrollbar-thumb{background:#26314c;border-radius:5px}::-webkit-scrollbar-track{background:transparent} + +/* buttons */ +.btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:8px;font-weight:700;border:1px solid transparent;transition:.15s;white-space:nowrap} +.btn:hover{transform:translateY(-1px);filter:brightness(1.12)} +.btn-primary{background:linear-gradient(135deg,#2563eb,#1d4ed8);color:#fff} +.btn-accent{background:linear-gradient(135deg,#ff8c33,#ff6a00);color:#160b02} +.btn-good{background:#0e9f6e;color:#03130d} +.btn-danger{background:#b91c1c;color:#fff} +.btn-ghost{background:var(--panel);border-color:var(--line);color:var(--txt)} +.btn-big{padding:12px 28px;font-size:16px;border-radius:12px} +.btn-sm{padding:4px 9px;font-size:12px;border-radius:6px} +.icon-btn{padding:7px 10px} +.btn:disabled{opacity:.4;cursor:not-allowed;transform:none} + +/* full screens */ +.screen-full{position:fixed;inset:0;background:radial-gradient(1200px 700px at 70% -10%,#16213c 0%,var(--bg) 60%);z-index:50;display:flex;align-items:center;justify-content:center;overflow:auto} + +/* title */ +.title-hero{text-align:center;padding:40px} +.title-logo{font-size:64px;font-weight:900;letter-spacing:.06em;color:#fff;text-shadow:0 0 40px rgba(255,122,26,.35)} +.title-logo span{color:var(--accent)} +.title-sub{color:var(--muted);font-size:18px;margin:6px 0 42px;letter-spacing:.25em;text-transform:uppercase} +.title-menu{display:flex;flex-direction:column;gap:14px;width:320px;margin:0 auto} +.title-foot{margin-top:56px;color:var(--dim,#54617e);font-style:italic} + +/* wizard */ +.wizard{width:min(1080px,96vw);max-height:94vh;overflow:auto;background:rgba(10,14,23,.75);border:1px solid var(--line);border-radius:16px;padding:28px 32px} +.diff-row{display:flex;gap:10px;margin:10px 0 18px} +.diff-row label{flex:1;display:flex;flex-direction:column;gap:3px;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px;cursor:pointer} +.diff-row label:has(input:checked){border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)} +.diff-row input{position:absolute;opacity:0} +.diff-row span{color:var(--muted);font-size:11px} +.team-pick-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px} +.team-pick{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px;cursor:pointer;transition:.15s} +.team-pick:hover{border-color:var(--accent);transform:translateY(-2px)} +.team-pick .tp-name{font-weight:800;font-size:15px} +.team-pick .tp-meta{display:flex;justify-content:space-between;color:var(--muted);font-size:11px;margin-top:4px} +.or-sep{text-align:center;color:var(--muted);margin:20px 0} +.custom-org{background:var(--panel);border:1px dashed var(--line);border-radius:12px;padding:16px} +.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;margin:10px 0} +.form-grid label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--muted)} + +/* game shell layout */ +#gameShell{display:grid;grid-template:auto 1fr / 190px 1fr;height:100vh} +#topbar{grid-column:1/3;display:flex;align-items:center;gap:18px;padding:8px 16px;background:linear-gradient(180deg,var(--panel),var(--bg2));border-bottom:1px solid var(--line)} +.org-badge{display:flex;align-items:center;gap:9px;font-weight:900;font-size:16px} +.org-badge .dot{width:14px;height:14px;border-radius:4px} +.org-badge small{display:block;font-size:10px;color:var(--muted);font-weight:600;letter-spacing:.08em} +.tb-stats{display:flex;gap:16px;margin-left:8px} +.tb-stat{display:flex;flex-direction:column;line-height:1.15} +.tb-stat b{font-size:14px}.tb-stat .ic{font-size:10px;color:var(--muted)} +.tb-date{margin-left:auto;text-align:right} +#tbDate{font-weight:800;font-size:15px;display:block} +.tb-actions{display:flex;gap:8px} +#sidebar{background:var(--bg2);border-right:1px solid var(--line);padding:10px 8px;display:flex;flex-direction:column;gap:4px} +.nav-btn{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:8px;color:var(--muted);font-weight:600;text-align:left;width:100%} +.nav-btn:hover{background:var(--panel);color:var(--txt)} +.nav-btn.active{background:linear-gradient(90deg,rgba(255,122,26,.16),transparent);color:#fff;border-left:3px solid var(--accent)} +.pill{background:var(--accent);color:#160b02;border-radius:99px;padding:0 6px;font-size:11px} +#view{overflow-y:auto;padding:18px 22px 60px} + +/* cards & panels */ +.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--rad);padding:14px 16px} +.card h3:first-child{margin-top:0} +.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px} +.stat-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px} +.tile{background:var(--panel);border:1px solid var(--line);border-radius:var(--rad);padding:12px} +.tile .t-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em} +.tile .t-val{font-size:22px;font-weight:900;margin-top:2px} + +/* tables */ +table.tbl{width:100%;border-collapse:collapse;font-size:13px} +.tbl th{color:var(--muted);text-transform:uppercase;font-size:10px;letter-spacing:.07em;text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)} +.tbl td{padding:7px 8px;border-bottom:1px solid rgba(35,45,69,.55);vertical-align:middle} +.tbl tr:hover td{background:rgba(56,189,248,.05)} +.tbl tr.clickable{cursor:pointer} +.num{text-align:right;font-variant-numeric:tabular-nums} + +/* player bits */ +.role-tag{font-size:10px;font-weight:800;letter-spacing:.04em;padding:2px 7px;border-radius:5px;background:var(--panel2);border:1px solid var(--line)} +.ovr{display:inline-flex;align-items:center;justify-content:center;min-width:34px;height:34px;border-radius:8px;font-weight:900;font-size:15px;background:linear-gradient(135deg,#334155,#1e293b)} +.ovr.s80{background:linear-gradient(135deg,#f59e0b,#d97706);color:#201200} +.ovr.s90{background:linear-gradient(135deg,#ef4444,#dc2626);color:#fff} +.bar{height:6px;background:#1b2438;border-radius:4px;overflow:hidden;min-width:54px} +.bar>i{display:block;height:100%;border-radius:4px} +.bar.morale>i{background:var(--good)}.bar.fatigue>i{background:var(--warn)}.bar.form>i{background:var(--accent2)} +.flag{font-size:15px} +.nick{font-weight:800} + +/* morale faces */ +.face{font-size:13px} + +/* modal */ +.modal-bg{position:fixed;inset:0;background:rgba(4,7,13,.72);z-index:80;display:flex;align-items:center;justify-content:center;padding:20px} +.modal{background:var(--panel);border:1px solid #2b3652;border-radius:14px;width:min(720px,95vw);max-height:92vh;overflow:auto;padding:22px 24px;box-shadow:0 30px 80px rgba(0,0,0,.6)} +.modal h2{font-size:19px} +.modal .close-x{float:right;color:var(--muted);font-size:20px} +.seg{display:inline-flex;background:var(--bg2);border:1px solid var(--line);border-radius:8px;overflow:hidden} +.seg button{padding:6px 12px;font-weight:700;color:var(--muted)} +.seg button.on{background:var(--accent);color:#160b02} + +/* toasts */ +#toastRoot{position:fixed;right:16px;bottom:16px;z-index:120;display:flex;flex-direction:column;gap:8px;max-width:360px;pointer-events:none} +.toast{background:var(--panel2);border-left:4px solid var(--accent);border-radius:8px;padding:10px 14px;box-shadow:0 10px 30px rgba(0,0,0,.5);animation:slideIn .25s ease;pointer-events:none} +.toast.good{border-color:var(--good)}.toast.bad{border-color:var(--bad)}.toast.warn{border-color:var(--warn)} +@keyframes slideIn{from{transform:translateX(30px);opacity:0}} + +/* news list */ +.news-item{display:flex;gap:12px;padding:10px 4px;border-bottom:1px solid rgba(35,45,69,.55)} +.news-item .n-date{min-width:74px;color:var(--muted);font-size:11px;padding-top:2px} +.news-item .n-title{font-weight:700} +.news-item.unread .n-title::before{content:"● ";color:var(--accent)} + +/* sim overlay */ +#simWrap{width:100%;height:100%;display:flex;flex-direction:column;background:radial-gradient(1400px 800px at 50% -20%,#17233f,var(--bg));padding:14px 18px} +.sim-scorebar{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:14px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px 18px} +.sim-team{font-size:21px;font-weight:900;display:flex;align-items:center;gap:10px} +.sim-team.right{justify-content:flex-end} +.sim-round-info{text-align:center} +.sim-round-info .rlabel{font-size:10px;color:var(--muted);letter-spacing:.15em;text-transform:uppercase} +.sim-round-info .rscore{font-size:36px;font-weight:900;letter-spacing:.06em} +.momentum{height:8px;background:linear-gradient(90deg,var(--accent) 0%,#333 50%,var(--accent2) 100%);border-radius:5px;position:relative;margin-top:6px} +.momentum>i{position:absolute;top:-3px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .4s;box-shadow:0 0 10px rgba(255,255,255,.7)} +.sim-map-line{display:flex;justify-content:center;gap:18px;color:var(--muted);font-size:12px;margin:8px 0;flex-wrap:wrap} +.sim-body{display:grid;grid-template-columns:1fr 340px;gap:14px;flex:1;min-height:0;margin-top:10px} +.sim-feed{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px;overflow-y:auto;font-size:13px} +.sim-feed p{padding:2.5px 0;border-bottom:1px dotted rgba(35,45,69,.5)} +.sim-feed .kf{color:var(--muted);font-family:"JetBrains Mono",Consolas,monospace;font-size:12px} +.sim-side{display:flex;flex-direction:column;gap:10px;overflow-y:auto} +.sim-panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px} +.buy-grid,.tac-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.choice{background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:9px 10px;text-align:left;transition:.12s} +.choice:hover:not(:disabled){border-color:var(--accent);transform:translateY(-1px)} +.choice b{display:block}.choice small{color:var(--muted);line-height:1.25;display:block} +.choice.on{border-color:var(--accent2);box-shadow:0 0 0 1px var(--accent2)} +.eco-cash{display:flex;justify-content:space-between;font-family:monospace;font-size:13px;margin-bottom:6px} +.map-card{border:1px solid var(--line);border-radius:9px;padding:10px;text-align:center;cursor:pointer;transition:.12s;background:var(--panel2)} +.map-card:hover:not(.dead){border-color:var(--accent);transform:translateY(-2px)} +.map-card.dead{opacity:.32;cursor:not-allowed;text-decoration:line-through} +.map-card.picked{border-color:var(--accent2);box-shadow:0 0 0 1px var(--accent2)} +.veto-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;margin:14px 0} +.clutch-choice{animation:pulse 1s infinite alternate} +@keyframes pulse{from{box-shadow:0 0 0 0 rgba(255,122,26,.5)}to{box-shadow:0 0 0 6px rgba(255,122,26,0)}} + +/* misc widgets */ +.tagchip{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:1px 8px;font-size:11px;margin:2px} +.kv{display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px dotted rgba(35,45,69,.6)} +.offer-card{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:10px} +.progress-note{font-size:11px;color:var(--muted)} +.bracket-note{background:rgba(56,189,248,.07);border:1px solid rgba(56,189,248,.25);border-radius:8px;padding:8px 12px;font-size:12px} +.stars{color:var(--gold);letter-spacing:1px} +@media(max-width:900px){ + #gameShell{grid-template:auto 1fr/1fr;} #sidebar{flex-direction:row;overflow-x:auto;border-right:none;border-top:1px solid var(--line);order:3} + .nav-btn{white-space:nowrap} #topbar{flex-wrap:wrap} .sim-body{grid-template-columns:1fr} +} + +/* simulation minimap viz */ +.viz-panel{padding:6px} +#simCanvas{width:100%;height:auto;display:block;border-radius:10px;background:#0b111d;border:1px solid #1d2a3f} + +/* === simulation: battlefield as the main stage === */ +.sim-round{display:flex;flex-direction:column;height:100%;min-height:0;gap:10px} +.sim-stage{flex:1;min-height:0;position:relative;display:flex;align-items:stretch; + background:#0a0f18;border:1px solid var(--line);border-radius:14px;overflow:hidden} +#simCanvas{width:100%;height:100%;display:block} +.stage-note{position:absolute;left:12px;bottom:10px;background:rgba(8,12,20,.72); + padding:4px 12px;border-radius:8px;pointer-events:none;z-index:2} +.sim-dock{display:grid;grid-template-columns:minmax(360px,1fr) minmax(280px,440px);gap:12px; + min-height:120px;max-height:24vh} +.dock-feed{overflow-y:auto;max-height:22vh} +@media(max-width:900px){.sim-dock{grid-template-columns:1fr;max-height:none}} diff --git a/index.html b/index.html new file mode 100644 index 0000000..861944c --- /dev/null +++ b/index.html @@ -0,0 +1,111 @@ + + + + + +CS Manager 2026 — Counter-Strike Esports Manager + + + + + +
+
+ +
Counter-Strike Esports Organization Manager
+
+ + + +
+
No story mode. No game over. Just you, your team, and the long road to legend.
+
+
+ + + + + + + + + + + +
+
+ + + + + + + + + + + diff --git a/js/data.js b/js/data.js new file mode 100644 index 0000000..e2a9ee8 --- /dev/null +++ b/js/data.js @@ -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, + }; +} diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..ef212a2 --- /dev/null +++ b/js/game.js @@ -0,0 +1,1030 @@ +'use strict'; +/* ================= core game engine ================= */ +const SAVE_KEY='csm26_save_v1'; +let G=null; + +const DIFFS={ + easy: {label:'Rookie',wageMul:0.85,aiAggro:0.6,startMul:1.6,boardStrict:0.7}, + normal:{label:'Pro', wageMul:1.0, aiAggro:1.0,startMul:1.0,boardStrict:1.0}, + hard: {label:'Legend',wageMul:1.15,aiAggro:1.5,startMul:0.65,boardStrict:1.4}, +}; +const TRAINING_FOCUS=['Aim','Utility','Tactics','Teamwork','Movement','Rest','Media']; +const FOCUS_ATTR={Aim:'aim',Utility:'util',Tactics:'iq',Teamwork:'comm',Movement:'move'}; +const PRIZE_FR=[1,.5,.3,.3,.15,.15,.15,.15,.06]; +const PTS_FR=[1,.6,.38,.38,.24,.24,.24,.24,.14]; + +const Game={ + diff:null, + + /* ---------- world creation ---------- */ + newWorld(diffKey){ + G={ver:1,day:0,diffKey,diff:DIFFS[diffKey], + money:0,fans:5000,rep:35,board:62, + players:{},teams:{},staff:{},freeAgents:[], + calendar:[],news:[],nextNewsId:1, + sponsors:[],sponsorOffers:[], + ranking:[],rankingPrev:[], + achievements:{},grandSlamWins:[], + season:1, history:[], + pendingOffers:[], // incoming offers for user players + milestones:[],scoutPool:[], + flags:{}, ui:{}, + }; + // backfill for older saves + if(!G.milestones)G.milestones=[]; + if(!G.scoutPool)G.scoutPool=[]; + // teams + TEAMS_DATA.forEach((t,i)=>{ + const id='t'+i; + G.teams[id]={id,name:t.name,tag:t.tag,color:t.color,tier:t.tier,region:t.region, + roster:[],lineup:[],staff:{},ratingPts:[1200,900,700][t.tier-1]+U.ri(-80,80), + fam:U.ri(50,95),mapAff:{},isPlayer:false}; + t.roster.forEach(pt=>{ + const p=makePlayerFromTuple(pt,id,G); + p.salary=this._aiSalary(p); + G.players[p.id]=p; G.teams[id].roster.push(p.id); + }); + this.genMapAff(G.teams[id]); + }); + // free agents + FREE_AGENTS.forEach(t=>{ + const p=makePlayerFromTuple(t,null,G); + p.salary=0; p.contractUntil=0; + G.players[p.id]=p; G.freeAgents.push(p.id); + }); + // staff pool (unemployed) + STAFF_POOL.forEach(t=>{ + const sid='s'+U.uid(); + G.staff[sid]={id:sid,nick:t[0],name:t[1],role:t[2],skill:t[3],salary:t[4],teamId:null}; + }); + // assign some staff to AI teams (flavor) + const poolIds=Object.keys(G.staff); + Object.values(G.teams).forEach(t=>{ + if(U.chance(.8)){const s=G.staff[U.pick(poolIds)]; if(!s.teamId){s.teamId=t.id; t.staff.Coach=s.id;} } + if(U.chance(.6)){const s=G.staff[U.pick(poolIds)]; if(!s.teamId){s.teamId=t.id; t.staff.Analyst=s.id;} } + }); + // calendar + CAL_2026.forEach((e,i)=>{ + G.calendar.push({id:'e'+i,...e,startDay:e.day,status:'open',teams:[],champion:null, + rounds:[],registered:false,userOut:false}); + }); + // initial lineup = best five by overall + Object.values(G.teams).forEach(t=>this.autoLineup(t)); + }, + + genMapAff(team){ + ACTIVE_MAPS.forEach(m=>{ team.mapAff[m]=+(U.gauss(0,3)).toFixed(1); }); + }, + _aiSalary(p){ + return Math.round(wageDemand(p,G)*0.95/250)*250; + }, + + startWithTeam(teamId){ + const t=G.teams[teamId]; + t.isPlayer=true; + G.playerTeamId=teamId; + G.money=Math.round([700000,1050000,1700000][t.tier-1]*G.diff.startMul); + this.pushNews('welcome',`Welcome to ${t.name}!`, + `You are the new General Manager of ${t.name}. The board expects progress. Build the roster, plan training, win trophies — your way.`,'org'); + this.pushNews('objective','Season objectives', + `Finish inside the TOP ${(t.tier===1?'8':t.tier===2?'14':'20')} of the world ranking this season and keep the club financially healthy.`,'board'); + this.computeRanking(); + }, + startCustom(){ + const name=document.getElementById('cName').value.trim()||'Northern Lights'; + const tag=(document.getElementById('cTag').value.trim()||'NLHT').toUpperCase(); + const region=document.getElementById('cRegion').value; + const budgetSel=document.getElementById('cBudget').value; + G.money=budgetSel==='small'?250000:budgetSel==='mid'?600000:1500000; + const id='tcust'; + const color='#'+['ff7a1a','38bdf8','34d399','a78bfa','f43f5e'][U.ri(0,4)]; + G.teams[id]={id,name,tag,color,tier:4,region,roster:[],lineup:[],staff:{}, + ratingPts:420,fam:20,mapAff:{},isPlayer:true}; + G.playerTeamId=id; + // draft 5 prospects + const roles=['IGL','AWP','Entry','Support','Rifler']; + roles.forEach(r=>{ + const nick=U.pick(NICK_SYL1)+U.pick(NICK_SYL2); + const p={ + id:U.uid(),nick,name:U.pick(FIRST_NAMES)+' '+U.pick(LAST_NAMES),age:U.ri(17,21), + nat:U.pick(Object.keys(NATIONS)),role:r, + attrs:Object.fromEntries(['aim','pos','clutch','util','iq','move','comm'].map(k=>[k,U.ri(48,64)])), + potential:U.ri(82,96), + form:5,morale:75,motivation:75,fatigue:8,injuryDays:0,teamId:id, + contractUntil:G.day+700,salary:U.ri(800,1800),listedPrice:null,loanTo:null, + personality:{prof:U.ri(40,85),temper:U.ri(30,80),ambition:U.ri(60,95)}, + stats:this.zeroStats(),seasonStats:this.zeroStats(), + training:{focus:'Balanced',intensity:1},talkCooldown:0,promise:null}; + G.players[p.id]=p; G.teams[id].roster.push(p.id); + }); + this.genMapAff(G.teams[id]); this.autoLineup(G.teams[id]); + this.pushNews('welcome',`A new era begins — ${name}`, + `You founded ${name} (${tag}) in the ${region} region. Five hungry prospects signed their first contracts. Prove the doubters wrong.`,'org'); + this.pushNews('objective','Season objectives', + 'Turn a squad of semi-pros into a ranked organization. Climb into the world TOP 30 and stay solvent.','board'); + this.computeRanking(); + if(typeof UI!=='undefined'&&UI.enterGame)UI.enterGame(); + }, + zeroStats(){return {maps:0,kills:0,deaths:0,adr:0,ratingSum:0,mvp:0,aces:0};}, + + /* ---------- helpers ---------- */ + me(){return G.teams[G.playerTeamId];}, + team(id){return G.teams[id];}, + p(pid){return G.players[pid];}, + teamPlayers(t){return t.roster.map(id=>G.players[id]);}, + lineupPlayers(t){return t.lineup.map(id=>G.players[id]).filter(Boolean);}, + autoLineup(t){ + const avail=t.roster.filter(pid=>!G.players[pid].injuryDays && !G.players[pid].loanFrom); + avail.sort((a,b)=>playerOverall(G.players[b])-playerOverall(G.players[a])); + t.lineup=avail.slice(0,Math.min(5,avail.length)); + if(t.lineup.length<5){ // fill with injured if desperate + t.roster.filter(pid=>!t.lineup.includes(pid)&&!G.players[pid].loanFrom).slice(0,5-t.lineup.length) + .forEach(pid=>t.lineup.push(pid)); + } + }, + staffOf(t,role){return t.staff[role]?G.staff[t.staff[role]]:null;}, + + teamStrength(t,opt={}){ + const ps=this.lineupPlayers(t); + if(!ps.length)return 40; + let base=U.avg(ps.map(playerOverall)); + const formAdj=U.avg(ps.map(p=>(p.form-5.5)*0.8)); + const morAdj=U.avg(ps.map(p=>(p.morale-60)*0.018)); + const fatAdj=-Math.max(0,U.avg(ps.map(p=>p.fatigue))-55)*0.05; + const chem=(t.fam-60)*0.02; + let coachBonus=0,analystBonus=0; + const c=this.staffOf(t,'Coach'); if(c)coachBonus=(c.skill-55)*0.035; + const a=this.staffOf(t,'Analyst'); if(a)analystBonus=(a.skill-55)*0.02; + return U.clamp(base+formAdj+morAdj+fatAdj+chem+coachBonus+analystBonus,30,99); + }, + + /* season objectives for the user's club */ + objectives(){ + const t=this.me(); + const targetRank=t.tier===1?8:t.tier===2?14:30; + const rank=this.rankOf(t.id)||99; + return { + rank,targetRank, + rankOk:rank<=targetRank, + moneyOk:G.money>-100000, + board:Math.round(G.board), + }; + }, + + /* ---------- rankings ---------- */ + computeRanking(){ + G.rankingPrev=G.ranking.slice(); + G.ranking=Object.values(G.teams).sort((a,b)=>b.ratingPts-a.ratingPts).map(t=>t.id); + }, + rankOf(teamId){return G.ranking.indexOf(teamId)+1;}, + + /* ---------- news ---------- */ + pushNews(type,title,body,tag,opts={}){ + const n={id:G.nextNewsId++,day:G.day,type,title,body,tag:tag||'',read:false,action:opts.action||null}; + G.news.unshift(n); + if(G.news.length>140)G.news.length=140; + return n; + }, + + /* ---------- time ---------- */ + advanceWeek(){ + this._advDays=7; + return this._advanceLoop(); + }, + advanceDay(){this._advDays=1;return this._advanceLoop();}, + _advanceLoop(){ + this._stopFx=null; + while(this._advDays>0){ + const fx=this.advanceOneDay(); + this._advDays--; + if(fx){this._stopFx=fx;break;} + } + Save.save(true); + return this._stopFx; + }, + remainingAdvance(){return this._advDays|0;}, + resumeAdvance(){ + if(this._advDays<=0){this._advDays=0;return null;} + return this._advanceLoop(); + }, + + advanceOneDay(){ + G.day++; + const dow=U.dow(G.day); + /* daily */ + Object.values(G.players).forEach(p=>{ + if(p.injuryDays>0){p.injuryDays--; if(p.injuryDays===0)this.pushNews('medical',`${p.nick} is back in training`,`${p.nick} has recovered and is available for selection again.`,'medical');} + if(p.talkCooldown>0)p.talkCooldown--; + p.fatigue=U.clamp(p.fatigue-(p.training.focus==='Rest'?4.5:2)+ (p.training.intensity===2&&!p.injuryDays?1.5:0),0,100); + if(p.loanDaysLeft!==undefined&&p.loanDaysLeft>0){ + p.loanDaysLeft--; + if(p.loanDaysLeft===0)this.endLoan(p); + } + }); + /* squads must always field five — academy promotions cover gaps */ + Object.values(G.teams).forEach(t=>this.ensureSquad(t)); + /* career milestones */ + this.checkMilestones(); + /* incoming transfer bids expire */ + for(let i=G.pendingOffers.length-1;i>=0;i--){ + const b=G.pendingOffers[i]; + if(G.day>b.expires){ + G.pendingOffers.splice(i,1); + this.pushNews('offer','Bid expired',`${G.teams[b.from]?.name||'A club'} withdrew their ${U.fmtM(b.fee)} offer for ${this.p(b.pid)?.nick||'a player'}.`,'offer'); + } + } + /* events */ + G.calendar.forEach(ev=>this.tickEvent(ev)); + /* weekly */ + if(dow===0){this.weeklyTick();} + if(G.day%28===0){this.monthlyTick();} + /* find user fixture due today */ + const fx=this.findUserFixtureToday(); + if(fx)return fx; + return null; + }, + + genYouth(teamId,opts={}){ + const nick=U.pick(NICK_SYL1)+U.pick(NICK_SYL2); + const roles=['Rifler','Entry','Support','AWP','IGL','Lurker']; + const p={ + id:U.uid(),nick,name:U.pick(FIRST_NAMES)+' '+U.pick(LAST_NAMES), + age:opts.age||U.ri(16,19), + nat:U.pick(Object.keys(NATIONS)),role:U.pick(roles), + attrs:Object.fromEntries(['aim','pos','clutch','util','iq','move','comm'].map(k=>[k,U.ri(44,62)])), + potential:opts.pot||U.ri(72,93), + form:4.5,morale:70,motivation:75,fatigue:6,injuryDays:0,teamId, + contractUntil:G.day+730,salary:Math.round(U.rnd(600,1500)/100)*100, + listedPrice:null,scouted:!!opts.scouted, + personality:{prof:U.ri(40,90),temper:U.ri(25,85),ambition:U.ri(55,95)}, + stats:this.zeroStats(),seasonStats:this.zeroStats(), + training:{focus:'Balanced',intensity:1},talkCooldown:0,promise:null}; + G.players[p.id]=p; + return p; + }, + + /* ---------- youth scouting ---------- */ + SCOUT_COST:80000, + scoutYouth(){ + if(G.moneyp.id); + return{ok:true,list}; + }, + signProspect(pid){ + const p=G.players[pid]; + if(!p||!(G.scoutPool||[]).includes(pid))return{ok:false,msg:'Prospect not available.'}; + const me=this.me(); + if(me.roster.length>=8)return{ok:false,msg:'Roster is full (8 players max).'}; + (G.scoutPool||[]).forEach(x=>{if(x!==pid)delete G.players[x];}); + G.scoutPool=[]; + p.teamId=me.id;p.contractUntil=G.day+1095; + p.salary=Math.round(wageDemand(p,G)*0.45/100)*100; + me.roster.push(p.id); + this.pushNews('transfer',`🎓 Academy signing: ${p.nick}`, + `${p.nick} (${p.age}, ${p.role}, POT ${p.potential}) signs a three-year academy deal at ${U.fmtM(p.salary)}/mo.`,'transfer'); + return{ok:true}; + }, + + /* ---------- career milestones ---------- */ + MILESTONES:[ + {id:'first_win',name:'First Blood',desc:'Win your first official match', + chk:()=>G.resultsRecent.some(x=>x===1)}, + {id:'first_trophy',name:'Silverware',desc:'Lift your first trophy', + chk:()=>Object.keys(G.achievements).filter(k=>k.startsWith('trophy_')).length>=1}, + {id:'three_cups',name:'Dynasty Begins',desc:'Win three trophies', + chk:()=>Object.keys(G.achievements).filter(k=>k.startsWith('trophy_')).length>=3}, + {id:'top10',name:'Elite Company',desc:'Reach the world TOP 10', + chk:()=>Game.rankOf(G.playerTeamId)<=10&&Game.rankOf(G.playerTeamId)>0}, + {id:'top1',name:'Kings of the World',desc:'Become world #1', + chk:()=>Game.rankOf(G.playerTeamId)===1}, + {id:'millionaire',name:'Seven Figures',desc:'Hold $1,000,000 in the bank', + chk:()=>G.money>=1000000}, + {id:'star_signing',name:'Blockbuster',desc:'Field an 88+ rated superstar', + chk:()=>Game.teamPlayers(Game.me()).some(p=>playerOverall(p)>=88)}, + {id:'academy_star',name:'Academy Graduate',desc:'Develop a scouted youth to 80+ overall', + chk:()=>Game.teamPlayers(Game.me()).some(p=>p.scouted&&playerOverall(p)>=80)}, + {id:'fans_500k',name:'Global Brand',desc:'Reach 500,000 fans', + chk:()=>G.fans>=500000}, + {id:'grand_slam',name:'Grand Slam Champion',desc:'Win 4 elite events within one year', + chk:()=>!!G.achievements.grandSlam}, + ], + checkMilestones(){ + this.MILESTONES.forEach(m=>{ + if(G.milestones.includes(m.id))return; + let ok=false;try{ok=m.chk();}catch(e){} + if(ok){ + G.milestones.push(m.id); + this.pushNews('milestone',`🏆 Milestone unlocked: ${m.name}`,`${m.desc}. The organization grows ever stronger.`,'milestone'); + } + }); + }, + ensureSquad(t){ + if(!t)return; + let added=false; + let guard=0; + while(t.roster.length<5&&guard++<6){ + const p=this.genYouth(t.id);t.roster.push(p.id);added=true; + if(t.isPlayer)this.pushNews('transfer',`Academy graduate: ${p.nick}`, + `${t.name} promote ${p.age}-year-old ${p.nick} (${p.role}, POT ${p.potential}) from the academy to fill the roster.`,'transfer'); + } + const invalid=t.lineup.length<5||t.lineup.some(pid=>{const p=G.players[pid];return !p||p.teamId!==t.id||p.injuryDays>0&&!t.roster.some(x=>x!==pid&&G.players[x].injuryDays===0);}); + if(invalid)this.autoLineup(t); + }, + + findUserFixtureToday(){ + const me=this.me(); + for(const ev of G.calendar){ + if(ev.status!=='live')continue; + for(const rnd of ev.rounds)for(const m of rnd.matches){ + if(m.day===G.day&&(m.a===me.id||m.b===me.id)&&!m.winner&&m.a&&m.b)return m; + } + } + return null; + }, + + weeklyTick(){ + this.trainingWeekly(); + this.moraleDrift(); + this.contractCheck(); + this.aiTransferWindow(); + this.financesWeekly(); + this.fansWeekly(); + // rating decay + Object.values(G.teams).forEach(t=>{t.ratingPts=Math.max(150,t.ratingPts*0.996);}); + this.computeRanking(); + this.boardCheck(); + if(G.day>=364*G.season){this.newSeason();} + }, + + monthlyTick(){ + // salary payments happen weekly; monthly: sponsors pay + let inc=0; + G.sponsors.forEach(s=>inc+=s.monthly); + G.money+=inc; + if(inc>0)this.pushNews('finance',`Sponsor payments received`,`Partners paid out ${U.fmtM(inc)} this month.`,'finance'); + // rent/ops cost + const ops=this.me().tier===4?30000:45000; + G.money-=ops; + // sponsor offers quarterly-ish + if(G.day%84===28)this.genSponsorOffers(); + // AI squads occasionally shuffle lineups + Object.values(G.teams).forEach(t=>{if(!t.isPlayer&&U.chance(.25))this.autoLineup(t);}); + }, + + /* ---------- training & development ---------- */ + trainingWeekly(){ + const me=this.me(); + const coach=this.staffOf(me,'Coach'),physio=this.staffOf(me,'Physio'); + const gains=[]; + me.roster.forEach(pid=>{ + const p=G.players[pid]; + if(p.injuryDays>0)return; + const f=p.training.focus, inten=f==='Rest'?0:p.training.intensity; + if(f==='Rest'){ + p.fatigue=U.clamp(p.fatigue-14-(physio?(physio.skill-60)*0.08:0),0,100); + p.morale=U.clamp(p.morale+2,0,100); p.form=U.clamp(p.form+0.2,0,10); + return; + } + if(f==='Media'){ + G.fans+=U.ri(300,1400)*(this.staffOf(me,'Media Manager')?1.5:1)|0; + p.morale=U.clamp(p.morale+1,0,100); p.fatigue=U.clamp(p.fatigue-3,0,100); + return; + } + const attr=FOCUS_ATTR[f]; + const gap=p.potential-playerOverall(p); + const youth=p.age<=21?1.5:p.age<=24?1.25:p.age<=27?1.0:p.age<=30?0.6:0.3; + const coachFac=coach?0.7+coach.skill/130:0.55; + const gainP=U.clamp(gap*0.010,0,0.4)*youth*coachFac*(inten===2?1.5:inten===1?1:0.6); + if(attr&&U.chance(gainP)){ + const before=p.attrs[attr]; + p.attrs[attr]=U.clamp(p.attrs[attr]+U.rnd(0.4,1.1)+(gap>25?0.5:0),30,99); + if(gains.length<5&&p.attrs[attr]>before+0.35){ + gains.push(`${p.nick} +${(p.attrs[attr]-before).toFixed(1)} ${f.toLowerCase()}`); + } + } + // all attrs tiny drift with hard work for youngsters + if(p.age<=23&&U.chance(0.05)){const k=U.pick(Object.keys(p.attrs));p.attrs[k]=U.clamp(p.attrs[k]+0.3,30,99);} + p.form=U.clamp(p.form+(inten===2?0.15:-0.05)+U.gauss(0,0.3),0.5,10); + p.fatigue=U.clamp(p.fatigue+inten*7-(physio?(physio.skill-60)*0.06:0),0,100); + if(f==='Teamwork'){me.fam=U.clamp(me.fam+1.2,0,100);} + // injury risk + if(p.fatigue>86&&U.chance(0.10)){ + p.injuryDays=U.ri(7,21); p.fatigue=60; + this.pushNews('medical',`${p.nick} injured`,`${p.nick} overstressed ${attr==='aim'?'aim routines':'in practice'} and will be out ~${p.injuryDays} days (overuse injury).`,'medical'); + } + }); + me.fam=U.clamp(me.fam+1,0,100); + if(gains.length){ + this.pushNews('training','📈 Weekly training report', + `Notable gains this week: ${gains.join(' · ')}. Consistent focus + a quality coach compounds over a season.`,'training'); + } + }, + setTraining(pid,focus,intensity){ + const p=this.p(pid); + if(focus!==undefined)p.training.focus=focus; + if(intensity!==undefined)p.training.intensity=intensity; + }, + bootcamp(){ + const me=this.me(); + if(G.flags.bootcampUntil>G.day)return false; + if(G.money<20000)return 'nomoney'; + G.money-=20000; + G.flags.bootcampUntil=G.day+7; + me.fam=U.clamp(me.fam+8,0,100); + me.lineup.forEach(pid=>{const p=this.p(pid);p.morale=U.clamp(p.morale+4,0,100);p.attrs.iq=U.clamp(p.attrs.iq+U.rnd(0,0.8),30,99);}); + this.pushNews('training','Bootcamp complete','A week of intense practice together improved team chemistry and tactical understanding.',null); + return true; + }, + + /* ---------- morale / psychology ---------- */ + moraleDrift(){ + const psy=this.staffOf(this.me(),'Psychologist'); + Object.values(G.players).forEach(p=>{ + if(p.teamId===null)return; + let base=55+(p.personality.prof-50)*0.1; + const t=G.teams[p.teamId]; + const playing=t.lineup.includes(p.id); + if(!playing)base-=12; else base+=4; + if(p.promise){ + base+=p.promise.broken?-15:8; + if(G.day>p.promise.until){ + if(p.promise.broken){p.morale=U.clamp(p.morale-18,0,100); + this.pushNews('talk',`${p.nick} is frustrated`,`${p.nick} feels the promise made to him was broken. Morale dropped heavily.`,'talk'); + } + p.promise=null; + } + } + if(p.salary>0){ + const fair=wageDemand(p,G); + if(p.salary0)return {ok:false,msg:`${p.nick} needs space — try again in ${p.talkCooldown} days.`}; + p.talkCooldown=10; + const prof=p.personality.prof, temper=p.personality.temper, amb=p.personality.ambition; + let dM=0,dMo=0,txt='',ok=true; + const toneF={supportive:0,demanding:1,neutral:0.5}[tone]; + switch(topic){ + case 'praise': + ok=p.form>=4.5; txt= ok?`${p.nick} appreciated the recognition.`:`${p.nick} knows his form is poor — empty praise annoyed him.`; + dM= ok?6+toneF*2:-4; break; + case 'motivate': + dMo=(tone==='demanding'? (temper>60? -8: 10):(amb>60?8:4)); + ok=dMo>0; txt= ok?`${p.nick} looks fired up for the upcoming matches.`:`${p.nick} reacted badly to pressure. Motivation dropped.`; + break; + case 'role': + ok=U.chance(0.5+prof/200); + txt= ok?`${p.nick} accepts his current role in the lineup.`:`${p.nick} wants more importance in the team. Handle with care.`; + dM= ok?4:-8; break; + case 'discipline': + dM=-6-toneF*6; dMo=tone==='demanding'?6:-2; + txt=`You had a serious word about professionalism. ${temper>70?'It got tense — he pushed back.':'He took it professionally.'}`; + if(temper<=70)p.personality.prof=U.clamp(prof+2,0,99); + break; + case 'promise': + return this.makePromise(p); + case 'contract': + txt=`${p.nick} wants to be paid like a top player if he performs like one. His agent noted your interest.`; + dM=2; break; + } + p.morale=U.clamp(p.morale+dM,0,100); + p.motivation=U.clamp(p.motivation+dMo,0,100); + if(dM>0)p.relationship=(p.relationship||50)+3; if(dM<0)p.relationship=(p.relationship||50)-5; + return {ok,msg:txt,dM,dMo}; + }, + makePromise(p){ + if(p.promise){return {ok:false,msg:`You already have an open promise with ${p.nick}.`};} + p.promise={type:'playtime',broken:false,until:G.day+56}; + p.morale=U.clamp(p.morale+10,0,100); p.motivation=U.clamp(p.motivation+12,0,100); + this.pushNews('talk',`Promise made to ${p.nick}`,`You promised ${p.nick} a starter spot for the next 8 weeks. Break it and face the consequences.`,'talk'); + return {ok:true,msg:`Promise made: starter for 8 weeks. ${p.nick} is motivated — don't betray him.`}; + }, + checkPromisesAfterLineup(){ + this.me().roster.forEach(pid=>{ + const p=this.p(pid); + if(p.promise&&!p.promise.broken){ + const playing=this.me().lineup.includes(pid); + if(!playing){p.promise.broken=true;} + } + }); + }, + + /* ---------- contracts ---------- */ + contractCheck(){ + const warn=d=>d===60||d===30||d===7; + Object.values(G.players).forEach(p=>{ + if(p.teamId===null||p.loanFrom)return; + const left=p.contractUntil-G.day; + const t=G.teams[p.teamId]; + if(warn(left)&&t.isPlayer){ + this.pushNews('contract',`${p.nick}'s contract expires in ${left} days`, + `${p.nick} (${playerOverall(p).toFixed(0)} OVR, wage demand ~${U.fmtM(wageDemand(p,G))}/mo) will become a FREE AGENT. Renew now or risk losing him for nothing.`,'contract',{action:{kind:'renew',pid:p.id}}); + }else if(warn(left)&&!t.isPlayer&&U.chance(.5)){ + // AI renews most + p.contractUntil=G.day+U.ri(365,900); p.salary=this._aiSalary(p); + } + if(left<=0){ + if(t.isPlayer){ + G.freeAgents.push(p.id); p.teamId=null; p.salary=0; p.contractUntil=0; + t.roster=t.roster.filter(x=>x!==p.id); t.lineup=t.lineup.filter(x=>x!==p.id); + this.pushNews('transfer',`${p.nick} left the club`, + `${p.nick}'s contract expired and he signed nowhere yet — he is now a free agent. You receive nothing.`,'transfer'); + }else{ + if(U.chance(.72)){p.contractUntil=G.day+U.ri(365,900);} + else{ + t.roster=t.roster.filter(x=>x!==p.id); t.lineup=t.lineup.filter(x=>x!==p.id); + G.freeAgents.push(p.id); p.teamId=null;p.salary=0;p.contractUntil=0; + if(U.chance(.4))this.pushNews('transfer',`${p.nick} is a free agent`,`Contract expired — ${p.nick} hit the open market.`,'transfer'); + } + } + } + }); + }, + renewContract(pid,salary,years,bonus=0){ + const p=this.p(pid), me=this.me(); + const demand=wageDemand(p,G); + const ratio=salary/Math.max(demand,1); + let acc=U.logistic((ratio-1)*6+(bonus>0?0.4:0)+(p.morale-60)/150+(p.age>29?-0.3:0.1)); + if(years>3&&p.age>28)acc-=0.15; + if(ratio<0.75)acc*=0.25; + if(G.money=1?8:3),0,100); + this.pushNews('contract',`${p.nick} signs a new deal`, + `${p.nick} extended for ${years} year${years>1?'s':''} at ${U.fmtM(salary)}/mo${bonus?` (+${U.fmtM(bonus)} signing bonus)`:''}.`,'contract'); + return {ok:true,msg:`${p.nick} put pen to paper! 🖊️`}; + } + p.morale=U.clamp(p.morale-3,0,100); + const counter=Math.round(demand*1.1/500)*500; + return {ok:false,msg:`${p.nick}'s camp rejected the offer. They want ~${U.fmtM(counter)}/mo.`}; + }, + releasePlayer(pid){ + const p=this.p(pid),me=this.me(); + const comp=p.salary*Math.max(1,(p.contractUntil-G.day)/30)*0.5; + if(G.moneyx!==pid); me.lineup=me.lineup.filter(x=>x!==pid); + p.teamId=null;p.salary=0;p.contractUntil=0;G.freeAgents.push(pid); + this.checkPromisesAfterLineup(); + this.pushNews('transfer',`${p.nick} released`,`Contract terminated. Severance paid: ${U.fmtM(comp)}.`,'transfer'); + return {ok:true}; + }, + + /* ---------- transfers ---------- */ + listPlayer(pid,price){this.p(pid).listedPrice=price;}, + unlistPlayer(pid){this.p(pid).listedPrice=null;}, + signFreeAgent(pid,salary,years,bonus=0){ + const p=this.p(pid),me=this.me(); + if(me.roster.length>=8)return {ok:false,msg:'Roster is full (max 8). Sell or release someone first.'}; + const demand=wageDemand(p,G); + const ratio=salary/Math.max(demand,1); + let acc=U.logistic((ratio-1)*5+(p.morale-50)/200); + const rank=this.rankOf(me.id)||60; + acc+=U.clamp((60-rank)*0.004,-0.15,0.2); + if(ratio<0.8)acc*=0.3; + if(G.moneyx!==pid); + me.roster.push(pid); + me.fam=U.clamp(me.fam-6,0,100); + p.morale=U.clamp(p.morale+5,0,100); + this.pushNews('transfer',`${p.nick} joins ${me.name}!`, + `Free agent signing: ${playerOverall(p).toFixed(0)} OVR ${p.role} on a ${years}-year deal at ${U.fmtM(salary)}/mo.`,'transfer'); + return {ok:true,msg:`Welcome to the club, ${p.nick}! 🎉`}; + } + return {ok:false,msg:`${p.nick} declined — he expects around ${U.fmtM(Math.round(demand/500)*500)}/mo${rank>25?' (and doubts our project…)':'.'}`}; + }, + makeTransferOffer(pid,fee,salary,years){ + const p=this.p(pid),me=this.me(); + const seller=G.teams[p.teamId]; + if(!seller||seller.isPlayer)return {ok:false,msg:'Invalid target.'}; + if(me.roster.length>=8)return {ok:false,msg:'Roster is full (max 8).'}; + const val=playerValue(p,G); + let acc=U.logistic((fee/Math.max(val,1)-1.15)*5); + const wdemand=wageDemand(p,G); + acc+=U.logistic((salary/wdemand-1)*4)*0.3-0.15; + const myRank=this.rankOf(me.id)||60, theirRank=this.rankOf(seller.id)||60; + if(theirRankx!==pid); seller.lineup=seller.lineup.filter(x=>x!==pid); + this.autoLineup(seller); seller.fam=U.clamp(seller.fam-15,0,100); + p.teamId=me.id;p.salary=salary;p.contractUntil=G.day+years*364;p.listedPrice=null; + me.roster.push(pid); me.fam=U.clamp(me.fam-8,0,100); + this.pushNews('transfer',`${p.nick} transferred to ${me.name}`, + `Fee: ${U.fmtM(fee)} • Wage ${U.fmtM(salary)}/mo • ${years}y. The scene reacts to the blockbuster move.`,'transfer'); + return {ok:true,msg:`Transfer complete! ${p.nick} is yours. 💰`}; + } + const ask=Math.round(val*(theirRank0)return {ok:false,msg:`${p.nick} is injured and cannot be loaned out.`}; + if(me.roster.length-1<5)return {ok:false,msg:'You must keep at least 5 players on the roster.'}; + if(p.loanFrom)return {ok:false,msg:'Player is already on loan.'}; + p.loanFrom=me.id; p.loanDaysLeft=90; p.teamId=targetId; + me.roster=me.roster.filter(x=>x!==pid); me.lineup=me.lineup.filter(x=>x!==pid); + target.roster.push(pid); this.autoLineup(target); + const fee=Math.round(playerValue(p,G)*0.04/1000)*1000; + G.money+=fee; + this.pushNews('transfer',`${p.nick} loaned to ${target.name}`,`Development loan (90 days) for ${U.fmtM(fee)}. He'll get playing time there.`,'transfer'); + return {ok:true}; + }, + endLoan(p){ + const cur=G.teams[p.teamId],home=G.teams[p.loanFrom]; + cur.roster=cur.roster.filter(x=>x!==p.id);cur.lineup=cur.lineup.filter(x=>x!==p.id); + p.teamId=p.loanFrom;p.loanFrom=undefined;p.loanDaysLeft=undefined; + if(home){home.roster.push(p.id);this.autoLineup(home); + this.pushNews('transfer',`${p.nick} returned from loan`,`${p.nick} is back in the ${home.name} squad after his development spell.`,'transfer');} + }, + + /* transfer windows: two per season (post-spring ~day150-230, winter ~300-364) */ + inTransferWindow(){ + const d=((G.day-1)%364)+1; + return (d>=150&&d<=230)||(d>=300); + }, + aiTransferWindow(){ + if(!this.inTransferWindow())return; + const aggro=G.diff.aiAggro; + if(!U.chance(0.5*aggro))return; + const aiTeams=Object.values(G.teams).filter(t=>!t.isPlayer&&t.roster.length>3); + const buyer=U.pick(aiTeams); + // candidate: free agents or listed players better than buyer's weakest starter + const starters=buyer.lineup.map(id=>G.players[id]); + const weak=U.avg(starters.map(playerOverall)); + const pool=[ + ...G.freeAgents.map(id=>G.players[id]), + ...Object.values(G.players).filter(p=>p.listedPrice&&G.teams[p.teamId]&&!G.teams[p.teamId].isPlayer), + ...Object.values(G.players).filter(p=>!G.teams[p.teamId]||(!G.teams[p.teamId].isPlayer&&!p.listedPrice&&U.chance(0.06))), + ].filter(p=>p&&playerOverall(p)>weak+3&&(!p.loanFrom)&&p.teamId!==buyer.id); + if(!pool.length)return; + const target=U.pickW(pool,p=>playerOverall(p)); + const from=G.teams[target.teamId]; + if(from&&from.isPlayer){ + // bid on YOUR player → inbox offer + if(target.listedPrice===null&&U.chance(.5))return; + const fee=target.listedPrice??Math.round(playerValue(target,G)*U.rnd(0.9,1.3)/50000)*50; + const wage=Math.round(wageDemand(target,G)*U.rnd(1.05,1.35)/500)*500; + this.pendingOffers.push({pid:target.id,from:buyer.id,fee,wage,expires:G.day+10}); + this.pushNews('offer',`${buyer.name} bid for ${target.nick}`, + `${buyer.tag} offered ${U.fmtM(fee)} (his value: ${U.fmtM(playerValue(target,G))}) with wages of ${U.fmtM(wage)}/mo. Respond in the Inbox or Squad screen.`,'offer', + {action:{kind:'bid'}}); + }else if(from){ + const fee=Math.round(playerValue(target,G)*U.rnd(1.0,1.35)/50000)*50; + from.roster=from.roster.filter(x=>x!==target.id);from.lineup=from.lineup.filter(x=>x!==target.id); + this.autoLineup(from); + target.teamId=buyer.id;target.salary=this._aiSalary(target);target.listedPrice=null; + buyer.roster.push(target.id);this.autoLineup(buyer); + buyer.fam=U.clamp(buyer.fam-10,0,100);from.fam=U.clamp(from.fam-10,0,100); + this.pushNews('rumor',`TRANSFER: ${target.nick} → ${buyer.tag}`, + `${buyer.name} complete the signing of ${target.nick} (${playerOverall(target).toFixed(0)} OVR) from ${from.name} for a reported ${U.fmtM(fee)}.`,'rumor'); + }else{ + // FA signs + target.teamId=buyer.id;target.salary=this._aiSalary(target);target.contractUntil=G.day+U.ri(365,730); + G.freeAgents=G.freeAgents.filter(x=>x!==target.id); + buyer.roster.push(target.id);this.autoLineup(buyer); + } + }, + respondBid(i,accept){ + const b=G.pendingOffers[i]; if(!b)return; + const p=this.p(b.pid),me=this.me(); + G.pendingOffers.splice(i,1); + if(accept){ + G.money+=b.fee; + me.roster=me.roster.filter(x=>x!==b.pid);me.lineup=me.lineup.filter(x=>x!==b.pid); + const buyer=G.teams[b.from]; + p.teamId=buyer.id;p.salary=b.wage;p.listedPrice=null;p.promise=null; + buyer.roster.push(b.pid);this.autoLineup(buyer); + this.checkPromisesAfterLineup(); + me.fam=U.clamp(me.fam-10,0,100); + this.pushNews('transfer',`${p.nick} sold to ${buyer.name}`,`Deal done: ${U.fmtM(b.fee)} banked. Reinvest wisely.`,'transfer'); + }else{ + this.pushNews('offer',`Bid rejected`,`${this.me().name} turned down ${G.teams[b.from].tag}'s approach for ${p.nick}.`,'offer'); + } + }, + + /* ---------- finances ---------- */ + financesWeekly(){ + const me=this.me(); + let wages=0; + me.roster.forEach(pid=>wages+=G.players[pid].salary/4.33); + Object.values(me.staff).forEach(sid=>{const s=G.staff[sid];if(s)wages+=s.salary/4.33;}); + G.money-=Math.round(wages); + // merch & streaming + const media=this.staffOf(me,'Media Manager'); + const merch=G.fans*0.05*(media?1+media.skill/200:1); + G.money+=merch; + this.lastWeeklyWages=Math.round(wages);this.lastWeeklyMerch=Math.round(merch); + if(G.money<-400000&&!G.flags.debtWarned){ + G.flags.debtWarned=true; + this.pushNews('finance','⚠️ CRITICAL DEBT','The account is deep in red. Sell players, cut wages, or the board will force austerity.','finance'); + } + if(G.money>0)G.flags.debtWarned=false; + const fin=this.staffOf(me,'Finance Director'); + if(fin&&U.chance(0.2)){G.money+=Math.round(fin.skill*40);} // finds savings + }, + fansWeekly(){ + const me=this.me(); + const media=this.staffOf(me,'Media Manager'); + let growth=G.rep*2+(media?(media.skill-60)*8:0); + growth*=U.rnd(0.7,1.3); + G.fans=Math.max(500,Math.round(G.fans+growth)); + }, + genSponsorOffers(){ + const me=this.me();const rank=this.rankOf(me.id)||50; + const fin=this.staffOf(me,'Finance Director'); + G.sponsorOffers=[]; + for(let i=0;i<3;i++){ + const brand=U.pick(SPONSOR_BRANDS.filter(b=>!G.sponsors.some(s=>s.brand===b))); + const type=U.pick(SPONSOR_TYPES); + const quality=U.clamp(95-rank*1.1+G.rep*0.4+U.gauss(0,8),10,120); + const monthly=Math.round((quality*quality*3.6)*(fin?1+fin.skill/400:1)/500)*500; + G.sponsorOffers.push({id:U.uid(),brand,type,monthly,months:U.ri(6,18),req:rank}); + } + this.pushNews('sponsor','New sponsorship proposals',`${G.sponsorOffers.length} brands tabled offers. Review them in Finances.`,'sponsor',{action:{kind:'sponsor'}}); + }, + acceptSponsor(id){ + const off=G.sponsorOffers.find(o=>o.id===id);if(!off)return false; + if(G.sponsors.length>=3)return false; + G.sponsors.push({...off,until:G.day+off.months*28}); + G.sponsorOffers=G.sponsorOffers.filter(o=>o.id!==id); + this.pushNews('sponsor',`${off.brand} partnership signed`,`${off.type} deal worth ${U.fmtM(off.monthly)}/mo for ${off.months} months. Welcome aboard!`,'sponsor'); + return true; + }, + rejectSponsor(id){G.sponsorOffers=G.sponsorOffers.filter(o=>o.id!==id);}, + tickSponsors(){ + G.sponsors=G.sponsors.filter(s=>{ + if(G.day>s.until){this.pushNews('sponsor',`${s.brand} deal expired`,`The partnership with ${s.brand} ran its course.`,'sponsor');return false;} + return true; + }); + }, + + /* ---------- board ---------- */ + boardCheck(){ + const me=this.me(); + const strict=G.diff.boardStrict; + const recent=G.resultsRecent||(G.resultsRecent=[]); + const wr=recent.length?U.avg(recent)/1:0.5; + const expect=me.tier===1?0.52:me.tier===2?0.47:0.43; + let delta=(wr-expect)*10*strict + (G.money>0?0.7:-0.9)*strict; + G.board=U.clamp(G.board+U.clamp(delta,-3,3),0,100); + if(G.board<25&&!G.flags.boardWarned){G.flags.boardWarned=true; + this.pushNews('board','⚠️ Board is losing patience','Results and/or finances are below expectations. The board demands improvement soon.','board');} + if(G.board>=40)G.flags.boardWarned=false; + }, + recordResult(win){ + G.resultsRecent=G.resultsRecent||[]; + G.resultsRecent.push(win?1:0); + if(G.resultsRecent.length>10)G.resultsRecent.shift(); + }, + + /* ---------- tournaments ---------- */ + registerEvent(evId){ + const ev=G.calendar.find(e=>e.id===evId);if(!ev)return; + if(ev.status!=='open'||ev.registered)return; + const me=this.me(); + const rank=this.rankOf(me.id)||99; + const cutoff=ev.tier===1?20:ev.tier===2?40:999; + if(rank>cutoff&&ev.tier<3){ + // wildcard lottery chance + if(!U.chance(U.clamp(0.9-(rank-cutoff)*0.06,0.05,0.9))){ + return {ok:false,msg:`Ranking #${rank} is outside the invite zone (top ${cutoff}). Try lower tiers.`}; + } + } + ev.registered=true; + this.pushNews('event',`Registered: ${ev.name}`,`${me.name} will compete in ${ev.name} (starts ${U.fmtDate(ev.startDay)}).`,'event'); + return {ok:true}; + }, + lockEvents(){ + const me=this.me(); + G.calendar.forEach(ev=>{ + if(ev.status!=='open')return; + if(G.day>=ev.startDay-14){ + // build field + let field=[]; + if(ev.registered)field.push(me.id); + const others=Object.values(G.teams).filter(t=>t.id!==me.id) + .sort((a,b)=>b.ratingPts-a.ratingPts); + field.push(...others.slice(0,ev.slots-field.length).map(t=>t.id)); + if(field.length<4)field.push(...others.slice(ev.slots,ev.slots+4-field.length).map(t=>t.id)); + // trim to power of two for a clean bracket + const nPow=Math.max(4,Math.pow(2,Math.floor(Math.log2(Math.min(field.length,16))))); + ev.teams=field.slice(0,nPow); + ev.status='locked'; + if(ev.teams.includes(me.id)) + this.pushNews('event',`${ev.name} — final team list`, + `${ev.name} field is set (${ev.teams.length} teams, ${U.fmtM(ev.prize)} to the winner). Event starts ${U.fmtDateShort(ev.startDay)}.`,'event'); + } + }); + }, + tickEvent(ev){ + this.lockEvents(); + if(ev.status==='locked'&&G.day>=ev.startDay){ + ev.status='live'; + this.buildBracket(ev); + this.pushNews('event',`${ev.name} begins!`,`${ev.teams.length} teams fight for ${U.fmtM(ev.prize)}${ev.major?' and Major glory':''}.`,'event'); + } + if(ev.status==='live'){ + // cascade: fill next rounds when ready, then play everything due (past or today) + for(let iter=0;iter<12;iter++){ + let progressed=false; + ev.rounds.forEach(rnd=>{ + const i=ev.rounds.indexOf(rnd); + if(i>0&&!rnd.filled){ + const prev=ev.rounds[i-1]; + if(prev.matches.every(m=>m.winner)){ + this.advanceRound(ev,prev); + rnd.filled=true; + if(!rnd.matches[0].a)rnd.filled=false; // safety + progressed=true; + } + } + }); + ev.rounds.forEach(rnd=>{ + rnd.matches.forEach(m=>{ + if(m.day<=G.day&&!m.winner&&m.a&&m.b&&(m.a!==G.playerTeamId&&m.b!==G.playerTeamId)){ + MatchEngine.simulate(m,ev);progressed=true; + } + }); + }); + if(!progressed)break; + } + if(ev.rounds.length&&ev.rounds.at(-1).matches.every(m=>m.winner)){ + this.finishEvent(ev); + } + } + this.tickSponsors(); + }, + buildBracket(ev){ + const teams=U.shuffle(ev.teams.slice()); + const n=teams.length; + const offs=ev.days>=9?[0,2,4,ev.days-3]:ev.days>=7?[0,2,3,ev.days-2]:[0,1,2,3]; + let names; + if(n>=16)names=['Round of 16','Quarterfinal','Semifinal','Grand Final']; + else if(n>=8)names=['Quarterfinal','Semifinal','Grand Final']; + else names=['Semifinal','Grand Final']; + let idx=0; + for(let r=0;rm.winner); + nxt.matches.forEach((m,j)=>{m.a=winners[j*2];m.b=winners[j*2+1];}); + }, + finishEvent(ev){ + ev.status='done'; + const final=ev.rounds.at(-1).matches[0]; + const champ=final.winner, runner=final.winner===final.a?final.b:final.a; + ev.champion=champ; + const sfLosers=ev.rounds.at(-2).matches.map(m=>m.winner===m.a?m.b:m.a); + const qfLosers=ev.rounds.at(-3)?ev.rounds.at(-3).matches.map(m=>m.winner===m.a?m.b:m.a):[]; + const places=[champ,runner,...sfLosers,...qfLosers]; + places.forEach((tid,i)=>{ + const fr=i0)this.pushNews('finance',`${U.esc(ev.name)}: ${U.fmtM(money)} prize money`, + `Placement earnings banked. Current balance: ${U.fmtM(G.money)}.`,'finance'); + } + }); + const ct=G.teams[champ]; + this.pushNews('result',`🏆 ${ct.name} win ${ev.name}!`, + `${ct.tag} defeated ${G.teams[runner].tag} in the grand final. ${U.fmtM(ev.prize)} and ${ev.pts} ranking points to the champions.`,'result'); + if(ct.isPlayer){ + G.rep=U.clamp(G.rep+ (ev.major?8:ev.tier===1?5:2),0,100); + G.fans+=U.ri(20000,60000); + G.achievements[`trophy_${ev.id}`]={ev:ev.name,day:G.day}; + if(ev.gs){ + G.grandSlamWins.push({ev:ev.id,day:G.day}); + const recent=G.grandSlamWins.slice(-GS_REQUIRED); + if(recent.length>=GS_REQUIRED&&(recent.at(-1).day-recent[0].day)<=GS_WINDOW_DAYS){ + G.money+=GS_BONUS;G.achievements.grandSlam={day:G.day}; + this.pushNews('result','💎 GRAND SLAM COMPLETE!', + `By winning ${GS_REQUIRED} elite events in a row you completed the GRAND SLAM and banked a ${U.fmtM(GS_BONUS)} bonus. Legendary.`,'result'); + } + } + }else if(G.teams[runner].isPlayer){ + G.rep=U.clamp(G.rep+ (ev.major?5:3),0,100); + } + }, + + /* called by match engine after any match resolves */ + finishMatch(m,res){ + m.winner=res.winner;m.score=res.score;m.maps=res.maps;m.box=res.box; + const loser=res.winner===m.a?m.b:m.a; + const wTeam=G.teams[res.winner],lTeam=G.teams[loser]; + // board tracks the user's own form only + if(wTeam.isPlayer)this.recordResult(true); + else if(lTeam.isPlayer)this.recordResult(false); + // player stats & fatigue & form + const apply=(tid,won)=>{ + const t=G.teams[tid]; + t.lineup.forEach(pid=>{ + const p=G.players[pid];if(!p)return; + const st=res.box[pid]||{k:0,d:0,adr:40,ratingSum:1,mapsN:1}; + const rt=st.rating!==undefined?st.rating:(st.ratingSum||1)/Math.max(st.mapsN||1,1); + p.stats.maps++;p.seasonStats.maps++; + p.stats.kills+=st.k;p.seasonStats.kills+=st.k; + p.stats.deaths+=st.d;p.seasonStats.deaths+=st.d; + p.stats.adr=(p.stats.adr*(p.stats.maps-1)+st.adr)/p.stats.maps; + p.seasonStats.adr=p.stats.adr; + p.stats.ratingSum+=rt;p.seasonStats.ratingSum+=rt; + if(st.mvp)p.seasonStats.mvp++; + if(st.aces)p.seasonStats.aces+=st.aces; + p.fatigue=U.clamp(p.fatigue+U.rnd(6,11),0,100); + p.form=U.clamp(p.form+(rt>1.1?0.5:rt<0.85?-0.4:0.1)+ (won?0.25:-0.2),0.5,10); + p.morale=U.clamp(p.morale+(won?2.5:-2.5)+(rt>1.2?2:rt<0.8?-2:0),0,100); + }); + }; + apply(res.winner,true);apply(loser,false); + const sw=U.ri(8,20); + wTeam.ratingPts+=sw;lTeam.ratingPts=Math.max(100,lTeam.ratingPts-sw); + // post-match news for user matches + if(wTeam.isPlayer||lTeam.isPlayer){ + const meIsWin=wTeam.isPlayer; + const opp=meIsWin?lTeam:wTeam; + this.pushNews('match',`${meIsWin?'✅ Victory':'❌ Defeat'} vs ${opp.tag} (${res.score})`, + `${meIsWin?'We':'We'} ${res.score} against ${opp.name}.${res.note||''}`,'match'); + } + }, + + /* ---------- seasons ---------- */ + newSeason(){ + const rank=this.rankOf(G.playerTeamId)||99; + G.history.push({season:G.season,rank,money:G.money,fans:G.fans,trophies:Object.keys(G.achievements).length}); + G.season++; + Object.values(G.players).forEach(p=>{ + p.age++; + p.stats={...this.zeroStats()}; + }); + // extend calendar next year + const shift=364*(G.season-1); + CAL_2026.forEach((e,i)=>{ + if(G.calendar.some(x=>x.baseId==='e'+i&&x.seasonNo===G.season))return; + G.calendar.push({id:'e'+i+'y'+G.season,baseId:'e'+i,...e,startDay:e.day+shift, + seasonNo:G.season,status:'open',teams:[],champion:null,rounds:[],registered:false,userOut:false}); + }); + // NOTE: finished events are kept forever — they hold the user's match history + this.pushNews('season',`🎉 Season ${G.season-1} review`, + `Final world ranking: #${rank}. Balance: ${U.fmtM(G.money)}. A new season of events is published — chase greatness.`,'board'); + this.computeRanking(); + }, + + /* ---------- save/load ---------- */ + serialize(){return JSON.stringify(G);}, + staticLoad(json){ + const obj=JSON.parse(json); + if(!obj||obj.ver!==1)throw new Error('bad save'); + G=obj;G.diff=DIFFS[obj.diffKey]; + return true; + }, +}; + +/* tiny save facade (localStorage guarded for node tests) */ +const Save={ + store(){ + try{return (typeof localStorage!=='undefined')?localStorage:null;}catch(e){return null;} + }, + save(auto){ + const ls=this.store();if(!ls||!G)return; + try{ls.setItem(SAVE_KEY,Game.serialize());if(auto)ls.setItem(SAVE_KEY+'_ts',String(Date.now()));}catch(e){} + }, + exists(){const ls=this.store();if(!ls)return false;try{return !!ls.getItem(SAVE_KEY);}catch(e){return false;}}, + load(){const ls=this.store();if(!ls)return false;try{Game.staticLoad(ls.getItem(SAVE_KEY));return true;}catch(e){return false;}}, + wipe(){const ls=this.store();if(ls){try{ls.removeItem(SAVE_KEY);}catch(e){}}}, +}; diff --git a/js/live.js b/js/live.js new file mode 100644 index 0000000..f27d0c0 --- /dev/null +++ b/js/live.js @@ -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{ + 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=0&&y>=0&&xMath.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;i0.12&&s.life<0.9){ + const ex=x-s.x,ey=y-s.y; + if(ex*ex+ey*ey{ + 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&&nxx?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!a.isCT); + const nFake=atkTac==='fake'?2:0; + atks.forEach((a,i)=>{ + a.fake=iutilPlan.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])=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.pathI0.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&&tt0){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(dd4.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.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{ + 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()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;i0.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){ + 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; diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..6564d14 --- /dev/null +++ b/js/main.js @@ -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(); +}); diff --git a/js/match.js b/js/match.js new file mode 100644 index 0000000..98292c6 --- /dev/null +++ b/js/match.js @@ -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{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{ + 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(`🎙 ${Game.team(k==='a'?S.aId:S.bId).tag} 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(`R${c.round} ${tagA} ${out.buyA.toUpperCase()} vs ${out.buyB.toUpperCase()} → ${wTag} (${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(`R${c.round} ${tagA} ${cfg.buys.a.toUpperCase()} vs ${cfg.buys.b.toUpperCase()} → ${wTag}${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(); + }, +}; diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..226f878 --- /dev/null +++ b/js/ui.js @@ -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=`
${U.esc(me.name)}${me.tag} · #${Game.rankOf(me.id)||'—'} WORLD
`; + $('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=``; + 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=`

Club Dashboard

+
+
Balance
${U.fmtM(G.money)}
+
World Rank
#${rank}
+
Fans
${U.fmtNum(G.fans)}
+
Reputation
${Math.round(G.rep)}
+
Board
${Math.round(G.board)}%
+
Chemistry
${Math.round(me.fam)}%
+
+

🎯 Season ${G.season} Objectives

+
+
World ranking#${obj.rank} · target TOP ${obj.targetRank}
+
+
Finances${obj.moneyOk?'solvent ⚖️':'deficit ⚠️'}
+
+
Board confidence${obj.board}%
+
+
+
`; + html+=`

${liveEv?'🔴 LIVE — '+U.esc(liveEv.name):'📅 Upcoming'}

`; + if(liveEv){ + html+=this.bracketMini(liveEv)+ + ``; + }else{ + const nxt=G.calendar.find(e=>e.registered&&e.status!=='done'&&e.startDay>G.day); + html+=nxt?`

${U.esc(nxt.name)} starts ${U.fmtDate(nxt.startDay)}.

+

Tier ${nxt.tier} · ${U.fmtM(nxt.prize)} to the winner · ${nxt.slots} teams${nxt.major?' · MAJOR 🏆':''}

` + :`

No event registered.

`; + html+=`

Calendar ahead

`+upc.map(e=> + `
${U.esc(e.name)}${e.registered?' ':''}${U.fmtDateShort(e.startDay)} · T${e.tier}
`).join(''); + } + html+=`
`; + 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+=`

🏆 Trophy Cabinet

${ + G.achievements.grandSlam?'

💎 GRAND SLAM CHAMPION

':''}${ + trophies.length?trophies.map(([k,v])=>`
🥆 ${U.esc(v.ev)}${U.fmtDateShort(v.day)}
`).join('') + :'

Empty shelf. The first trophy tastes the sweetest.

' + }

⏳ Expiring Contracts

${ + expiring.map(p=>{const left=p.contractUntil-G.day; + return `
${p.nick} ${playerOverall(p).toFixed(0)}${left}d
`;}).join('') + ||'

All contracts secure ✔

' + }
`; + html+=`

🏅 Career Milestones

${Game.MILESTONES.map(m=>{ + const got=G.milestones.includes(m.id); + return `
${got?'✅':'🔒'} ${m.name}${got?'unlocked':U.esc(m.desc)}
`; + }).join('')}
`; + html+=`

Squad Snapshot

${ + sorted.slice(0,6).map(p=>` + + + + + `).join('') + }
PlayerFormMoraleFatigue
${playerOverall(p).toFixed(0)}${p.nick} ${FLAG(p.nat)}
${p.role}
${formStars(p.form)}${faceFor(p.morale)}
`; + html+=`

Recent Results

${ + recent.slice(0,6).map(({m,e})=>{ + const won=m.winner===me.id; + return `
${won?'✅':'❌'} vs ${Game.team(m.a===me.id?m.b:m.a).name}${m.score} ${U.esc(e.name)}
`; + }).join('')||'

No matches played yet.

' + }
`; + html+=`

Latest News

${ + G.news.slice(0,6).map(newsRow).join('')}
`; + $('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+=`
+ ${Game.team(m.a).tag} vs ${Game.team(m.b).tag}${mine?' ⭐':''} + ${m.winner?`${m.score} ${rnd.name}`:`${U.fmtDateShort(m.day)}`}
`; + }); + }); + return html||'

Bracket forming…

'; + }, + + /* ================= 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 ` + ${playerOverall(p).toFixed(0)} + ${starter}${p.nick} ${FLAG(p.nat)}${p.listedPrice!==null?' 🔁':''}
${U.esc(p.name)}, ${p.age} + ${p.role} + ${formStars(p.form)} + ${faceFor(p.morale)} ${Math.round(p.morale)} +
${p.injuryDays?`🏥${p.injuryDays}d`:''} + ${U.fmtM(p.salary)} + ${U.fmtM(playerValue(p,G))} + ${left<=0?'EXPIRED':left+'d'}`; + }).join(''); + $('view').innerHTML=`

Squad Management

+

Starting Lineup

+
${me.lineup.map((pid,i)=>{const p=Game.p(pid);return `${i+1}. ${p.nick} ${playerOverall(p).toFixed(0)}${p.injuryDays?' 🏥':''}`;}).join('')} + +
+
+
+ + ${rows}
OVRPlayerRoleFormMoraleFatigueWage/moValueContract
+

Roster cap: 8 players (${ps.length}/8).

`; + }, + 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)=>`
${label}
`; + const html=` +
${ovr.toFixed(0)} +

${p.nick} ${FLAG(p.nat)}

${U.esc(p.name)} · ${p.age}y · ${p.role}${p.potential>ovr+4&&mine?` · POT ${p.potential}`:''}
+
+

Attributes

${attrs.map(([l,vv])=>bar(l,vv)).join('')} +
Progress to potential
+
Form ${formStars(p.form)} · Morale ${faceFor(p.morale)} ${Math.round(p.morale)} · Fatigue ${Math.round(p.fatigue)}%${p.injuryDays?` · Injured ${p.injuryDays}d`:''}
+
Professionalism ${p.personality.prof} · Temperament ${p.personality.temper} · Ambition ${p.personality.ambition}
+

Season Stats

+
+
Maps
${s.maps}
+
Rating
${r?r.toFixed(2):'—'}
+
K/D
${s.maps&&s.d?(s.k/s.d).toFixed(2):'—'}
+
ADR
${s.maps?Math.round(s.adr):'—'}
+
MVPs
${s.mvp}
+
Clutches
${s.aces}
+
+
Contract${mine?(left>0?left+' days left':`expired ${-left}d ago`):U.fmtDate(p.contractUntil)}
+
Value ≈${U.fmtM(playerValue(p,G))}
+
+
+ ${mine?`
+ + ${inLineup + ?`` + :``} + + ${p.listedPrice!==null?`` + :``} + ${!inLineup&&Game.me().roster.length>5?``:''} + +
`: + `
+ ${p.teamId?`` + :``} + Wage demand ≈ ${U.fmtM(wageDemand(p,G))}/mo${p.listedPrice!==null?` · asking ${U.fmtM(p.listedPrice)}`:''} +
`}`; + 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=`

Transfer List — ${p.nick}

Market value: ${U.fmtM(val)}. Listed players attract bids from other clubs (see Inbox / Transfers → Bids).

+ + +
+
`; + 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 `
+
${FLAG(p.nat)} ${p.nick} ${p.age}y · ${p.role} · ${NATIONS[p.nat]||p.nat}
+ ${ovr.toFixed(0)} POT ${p.potential}★
+
+
${attrs.map(([l,v])=>`${l} ${v.toFixed(0)}`).join(' · ')}
+
`; + }; + this.modal(`

🔍 Scouting Camp — $80K

+

Three unattached youngsters caught our scouts' eyes. Sign one — the others return to obscurity. Potential scales with club reputation.

+
${r.list.map(card).join('')}
+
`); + }, + 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=`

Development Loan — ${p.nick}

+

Loaning a young player out for 90 days earns a small fee and accelerates his growth through playing time. He returns automatically afterwards.

+
${clubs.map(t=>` +
+
${U.esc(t.name)} Tier ${t.tier} · #${Game.rankOf(t.id)} · roster ${t.roster.length}/8
+ +
`).join('')}
+
`; + 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=` +

Talk to ${p.nick}

+

Morale ${faceFor(p.morale)} ${Math.round(p.morale)} · Motivation ${Math.round(p.motivation)} · Form ${formStars(p.form)}. One meaningful talk per ~10 days.

+

Topic

${ + topics.map(([id,l])=>``).join('') + }
+

Tone

${tones.map((t,i)=>``).join('')}
+
+
+
`; + 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='

Pick a topic first.

';return;} + const res=Game.applyTalk(pid,topic,tone); + $('talkResult').innerHTML=`

${U.esc(res.msg)}

+ ${res.dM!==undefined?`

Morale ${res.dM>=0?'+':''}${res.dM} · Motivation ${res.dMo>=0?'+':''}${res.dMo}

`:''} +
`; + 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'?``:''; + const html=` +

${title}

+

His camp wants ≈ ${U.fmtM(demand)}/mo. Current club: ${p.teamId?U.esc(Game.team(p.teamId).name):'free agent'} · morale ${faceFor(p.morale)} · OVR ${playerOverall(p).toFixed(0)}

+
+ ${feeField} + + + +
+
+
+
`; + 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=`
❌ ${U.esc(res.msg)} +
`; + }; + }, + + /* ================= 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=>``).join(''); + const intSel=[[0,'Light'],[1,'Normal'],[2,'Hard']].map(([v,l])=>``).join(''); + return ` + ${p.nick} ${p.role} + ${playerOverall(p).toFixed(0)} → ${p.potential} + + +
+ ${p.injuryDays?`🏥 ${p.injuryDays}d`:''}`; + }).join(''); + const bootcamping=(G.flags.bootcampUntil||0)>G.day; + $('view').innerHTML=`

Training Center

+
+

Weekly Plan

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.

+

Support Effects

+
Coach${coach?`${coach.nick} (+${Math.round(coach.skill-55)}% growth)`:'vacant'}
+
Physio${physio?`${physio.nick} (faster recovery)`:'vacant'}
+
Chemistry${Math.round(me.fam)}%
+ + ${bootcamping?'

Bootcamp in progress…

':''} +
+
+
${rowHtml}
PlayerLevelFocusIntensityFatigueStatus
`; + }, + 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 `
+
${p.nick} ${p.role} · ${playerOverall(p).toFixed(0)} OVR · value ${U.fmtM(playerValue(p,G))}
+ ${U.esc(from.name)} offers ${U.fmtM(b.fee)} + ${U.fmtM(b.wage)}/mo wages
+
+
`; + }).join('')||'

No incoming bids right now.

'; + }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)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 ` + ${playerOverall(p).toFixed(0)} + ${p.nick} ${FLAG(p.nat)}
${U.esc(p.name)}, ${p.age} + ${p.role} + ${p.potential} + ${U.fmtM(playerValue(p,G))} + ${isFA?'FREE AGENT':U.esc(Game.team(p.teamId)?.tag||'—')} + ${isFA?U.fmtM(wageDemand(p,G))+'/mo':(p.listedPrice?U.fmtM(p.listedPrice):'—')}`; + }).join('')||'Nobody matches those filters.'; + } + $('view').innerHTML=`

Transfer Center · ${win?'🟢 transfer window open':'⚪ closed'} (${winTxt})

+
+ + + + + ${tab==='market'?` + + + + + `:''} +
+ ${tab==='market'?`
+ + ${body}
OVRPlayerRolePotValueClubPrice
`:`
${body}
`}`; + }, + + /* ================= 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 `
+

${U.esc(e.name)} ${e.major?'🏆':''}${e.gs?' 💎':''}

+ Tier ${e.tier}
+
Starts${U.fmtDate(e.startDay)}
+
Winner prize${U.fmtM(e.prize)}
+
Points${e.pts}
+
Teams${e.teams.length||e.slots}
+ ${e.status==='open'?(regd?'

✓ Registered — field locks soon

' + :``) + :e.status==='live'?`

● LIVE${inField?' — you are competing!':''}

` + :`

Champion: ${e.champion?U.esc(Game.team(e.champion).name):'—'}

`} +
`; + }; + $('view').innerHTML=`

Tournament Circuit

+ ${G.achievements.grandSlam?'

💎 GRAND SLAM CHAMPION 💎

':`

Win ${GS_REQUIRED} of the 💎 elite events consecutively within a year for the Grand Slam (${U.fmtM(GS_BONUS)} bonus).

`} + ${groups.live.length?`

Happening now

${groups.live.map(card).join('')}
`:''} +

Upcoming & open

+
${groups.open.concat(groups.locked).map(card).join('')||'

Nothing open right now.

'}
+

Recently finished

${groups.done.slice(-4).reverse().map(card).join('')||'

'}
`; + }, + 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=` +

${U.esc(ev.name)} ${ev.major?'🏆':''}

+

${U.fmtDate(ev.startDay)} · Tier ${ev.tier} · ${U.fmtM(ev.prize)} winner prize · ${ev.pts} pts

`; + if(ev.champion)html+=`

🥇 Champion: ${U.esc(Game.team(ev.champion).name)}

`; + html+='
'; + ev.rounds.forEach(rnd=>{ + html+=`

${rnd.name} ${U.fmtDateShort(rnd.day)}

`; + rnd.matches.forEach(m=>{ + if(!m.a||!m.b){html+=`
TBD vs TBD
`;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+=`
+ ${winIsA?'':''}${A.tag}${winIsA?'':''} vs ${m.winner&&!winIsA?'':''}${B.tag}${m.winner&&!winIsA?'':''} ${mine?'⭐':''} + ${m.winner?`${m.score} ${(m.maps||[]).map(x=>`${x.map} ${x.score}`).join(' · ')}`:`${U.fmtDateShort(m.day)} · BO${m.bo}`}
`; + }); + }); + html+='
'; + const pending=Game.findUserFixtureToday(); + html+=`
${pending&&ev.status==='live'?``:''} +
`; + 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=`

Matches

+

Upcoming

${ + future.sort((a,b)=>a.m.day-b.m.day).map(({m,e})=>`
vs ${U.esc(opp(m).name)} BO${m.bo} · ${U.esc(e.name)} + ${U.fmtDate(m.day)}
`).join('')||'

Register for events to schedule matches.

'}
+

History

+ + ${past.map(({m,e})=>{ + const won=m.winner===me.id; + return ` + + `; + }).join('')}
DateEventOpponentResultScore
${U.fmtDateShort(m.day)}${U.esc(e.name)}${U.esc(opp(m).tag)}${won?'WIN':'LOSS'}${m.score}
+ ${past.length?'':'

No matches yet.

'}
`; + }, + 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)=>`

${U.esc(Game.team(tid).name)} ${m.winner===tid?'🏆':''}

+ + ${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 ` + `; + }).join('')}
PlayerKDADRRating
${m.mvp===pid?'🏅 ':''}${p.nick}${b.k}${b.d}${Math.round(b.adr/Math.max(b.mapsN||1,1))}${rt.toFixed(2)}
`; + const scoreParts=String(m.score).split('-'); + this.modal(` +

${U.esc(Game.team(m.a).tag)} ${U.esc(scoreParts[0]||'')} : ${U.esc(scoreParts[1]||'')} ${U.esc(Game.team(m.b).tag)}

+

${ev?U.esc(ev.name||'')+' · ':''}${(m.maps||[]).map(x=>`${x.map} ${x.score}`).join(' · ')}

+
${tbl(m.a,la)}${tbl(m.b,lb)}
+
+ ${opts.continueBtn?``:''} +
`,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 `

${role}

${s? + `
${s.nick} ${U.esc(s.name)}
+ ${'★'.repeat(Math.round(s.skill/20))} ${s.skill} skill · ${U.fmtM(s.salary)}/mo
+
` + :'

Vacant

'}
`; + }).join(''); + const pool=Object.values(G.staff).filter(s=>!s.teamId).sort((a,b)=>b.skill-a.skill); + $('view').innerHTML=`

Backroom Staff

+

Coaches accelerate training, analysts sharpen prep, psychologists stabilize morale, physios cut fatigue, media managers grow fans, finance directors find savings & better sponsors.

+
${cur}
+

Available Staff

+ + ${pool.map(s=>` + + + `).join('')||''}
NameRoleSkillSalary/mo
${s.nick} ${U.esc(s.name)}${s.role}${'★'.repeat(Math.round(s.skill/20))} ${s.skill}${U.fmtM(s.salary)}
Market is empty — check back later.
`; + }, + hireStaff(sid){ + const s=G.staff[sid];const me=Game.me(); + if(G.moneys+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=`

Finances

+
+
Balance
${U.fmtM(G.money)}
+
Monthly Net
${net>=0?'+':''}${U.fmtM(net)}
+
Wages+Staff
-${U.fmtM(wagesMo)}
+
Ops Cost
-${U.fmtM(ops)}
+
Merch/Stream
+${U.fmtM(merchMo)}
+
Sponsors
+${U.fmtM(sponsMo)}
+
+
+

Active Partnerships (${G.sponsors.length}/3)

${ + G.sponsors.map(s=>`
${U.esc(s.brand)} ${U.esc(s.type)}${U.fmtM(s.monthly)}/mo · ${Math.ceil(Math.max(0,(s.until-G.day))/28)}mo left
`).join('')||'

None yet.

'}
+

Pending Offers

${ + G.sponsorOffers.map(o=>`
${U.esc(o.brand)} ${U.esc(o.type)}
${U.fmtM(o.monthly)}/mo × ${o.months} months
+
+
`).join('')||'

New offers arrive periodically based on ranking, rep & fans.

'}
+
`; + }, + + /* ================= 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?'':move>0?`▲${move}`:move<0?`▼${-move}`:''; + return ` + #${i+1}${arrow} + ${U.esc(t.name)} ${t.isPlayer?'⭐':''} + ${t.region}${Math.round(t.ratingPts)} + ${t._lastPts?`+${t._lastPts}`:''}`; + }).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=`

World Rankings

+
+
${rows}
#TeamRegionPointsLast EV
+

Player Leaderboard (season rating)

+ + ${topPlayers.map((p,i)=>{ + const rt=p.seasonStats.ratingSum/p.seasonStats.maps; + return ` + `; + }).join('')||''}
#PlayerClubMapsRating
${i+1}${p.nick} ${FLAG(p.nat)}${U.esc(Game.team(p.teamId)?.tag||'FA')}${p.seasonStats.maps}${rt.toFixed(2)}
Play some maps!
+
+ ${G.history.length?`

Season History

+ + ${G.history.map(h=>``).join('')} +
SeasonFinal rankBalanceFansTrophies
S${h.season}#${h.rank}${U.fmtM(h.money)}${U.fmtNum(h.fans)}${h.trophies||0}
`:''}`; + }, + + /* ================= INBOX ================= */ + rInbox(){ + const items=G.news.slice(0,60); + $('view').innerHTML=`

Inbox

+
+
${items.map(n=>newsRow(n,true)).join('')||'

Empty.

'}
`; + }, + + /* ================= 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=`

⚔️ ${U.esc(Game.me().name)} vs ${U.esc(opp.name)}

+

${U.esc(evName||'')} · BO${fx.bo} · ${U.fmtDate(fx.day)}

+
+

${U.esc(Game.me().tag)} #${pv.rankA}

+

Avg OVR ${pv.sA.toFixed(1)}

+
${Game.lineupPlayers(Game.me()).map(p=>`${p.nick} ${playerOverall(p).toFixed(0)}${p.injuryDays?' 🏥':''}`).join('')}
+

${U.esc(opp.tag)} #${pv.rankB}

+

Avg OVR ${pv.sB.toFixed(1)}

+
${MatchEngine.lineupOf(opp.id).map(pid=>`${Game.p(pid).nick}`).join('')}
+
+

Estimated win chance: ${Math.round(pv.pWin*100)}%

+
+ + +
+

Simulation mode: live map veto, buy decisions, round tactics & timeouts. Quick sim lets your coach handle it.

`; + 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
${U.esc(Game.team(S.aId).name)}
+
MAP VETO · BO${S.bo}
🗳
+
${U.esc(Game.team(S.bId).name)}
+
+
+
+ MAP VETO · BO${S.bo} + +
+ ${done?`

Map pool set: ${v.seq.join(' → ')}

+ ` + :myTurn?`

Your move: ${isBan?'🚫 BAN':'✅ PICK'} a map.

` + :`

${aiTag} is deciding…

`} + ${!done?`
${ACTIVE_MAPS.map(mp=>{ + const gone=!v.pool.has(mp); + const aff=Game.me().mapAff[mp]||0; + const picked=v.seq.includes(mp); + return `
+ ${mp}
${aff>=0?'+':''}${aff.toFixed?aff.toFixed(1):aff} aff${picked?'
picked':''}
`; + }).join('')}
`:''} +
${v.log.map(l=>`

${l}

`).join('')}
+
+
`; + }, + simInterHtml(S){ + const c=S.cur; + const last=S.results[S.results.length-1]; + return `
${U.esc(Game.team(S.aId).name)}
+
SERIES
${S.scoreA}-${S.scoreB}
+
${U.esc(Game.team(S.bId).name)}
+
+

${last.map} — final score

+

${last.sa}-${last.sb}

+
${c.log.map(l=>l.startsWith('🔫')?`

${l}

`:`

${l}

`).join('')}
+
+
`; + }, + 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 `
${U.esc(Game.team(S.aId).name)}
+
FINAL
${S.scoreA}-${S.scoreB}
+
${U.esc(Game.team(S.bId).name)}
+
+

${iWon?'🏆 VICTORY!':'💀 DEFEAT'}

+

MVP: ${mvpP?`${mvpP.nick}`:'—'}

+ ${S.results.map(r=>`
${r.map}${r.sa}-${r.sb}
`).join('')} +
+ +
`; + }, + 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=`
Your team funds$${U.fmtNum(c.fund[k])}
+
${buyOpts.map(o=>``).join('')}
+ ${c.timeouts[k]>0?``:''} +

Tip: eco when under 6.8K, force with 6.8–11.8K. Mix tactics — smart opponents read patterns.

`; + }else if(S.awaiting==='tactic'){ + controls=`

Buy locked: ${String(S.userBuy).toUpperCase()}. Choose your approach:

+
${TACTICS.map(t=>``).join('')}
`; + }else if(S.awaiting==='anim'){ + controls=`
🔴 LIVE +
`; + }else if(S.awaiting==='next'){ + controls=` + `; + } + 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 `${p.nick} ${b?`${b.k}/${b.d}`:'0/0'}${p.injuryDays?' 🏥':''}`; + }).join(''); + return `
+
+
${U.esc(Game.team(S.aId).name)}
+
${U.esc(c.map)} · Round ${c.round+1} · MR12
+
${c.sa} : ${c.sb}
+
+
You: ${myScore} — ${opT.tag}: ${opScore} · OT target ${c.winScore}
+
${U.esc(Game.team(S.bId).name)}
+
${S.veto.seq.map((mp,i)=> + {const r=S.results[i]; + return `${mp}${r?` ${r.sa}-${r.sb}`:''}`;}).join(' · ')}
+
+ +
${ctIsA?'Your side: CT — hold the sites':'Your side: T — execute onto a site'}
+
+
+
+
+ + + + + + + +
+

${S.awaiting==='next'||S.awaiting==='anim'?'Round over':'Command the round'}

${controls} +
${lineupChips} + 🎙 ${c.timeouts[k]} timeouts left
+
+
${c.log.map(l=>l.startsWith('🔫')?`

${l}

`:`

${l}

`).join('')}
+
`; + }, + + /* ---------- 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=`

${iWon?'🏆 VICTORY':'💀 DEFEAT'} — ${U.esc(Game.team(res.winner).name)} win ${res.score}

+

${res.maps.map(x=>`${x.map} ${x.score}`).join(' · ')}${res.note||''}

+

MVP: ${mvp?`${mvp.nick} (${(b.ratingSum/b.mapsN).toFixed(2)})`:'—'}

+
+
`; + 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=` +

Career Menu

+
+ + + + +
+

Autosave runs every in-game week. Saves live in your browser's localStorage.

`; + 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 `
+
${U.esc(t.name)}
+
${t.region} · Tier ${t.tier}Avg ${(Game.teamPlayers(t).reduce((s,p)=>s+playerOverall(p),0)/5).toFixed(0)} OVR
+
${best.map(p=>p.nick).join(' · ')}
`; + }).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=`
`; + }else if(ACT[n.action.kind]){ + act=`
`; + } + } + return `
+ ${U.fmtDateShort(n.day)} +
${U.esc(n.title)}
+ ${full?`
${n.body}
`:''} + ${act} + ${n.tag}
`; +} diff --git a/js/util.js b/js/util.js new file mode 100644 index 0000000..290a8f1 --- /dev/null +++ b/js/util.js @@ -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;iMath.random(){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':'';} diff --git a/js/viz.js b/js/viz.js new file mode 100644 index 0000000..f69fc25 --- /dev/null +++ b/js/viz.js @@ -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;ithis.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{ + 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(); diff --git a/test/debug_ev.js b/test/debug_ev.js new file mode 100644 index 0000000..9d14979 --- /dev/null +++ b/test/debug_ev.js @@ -0,0 +1,23 @@ +'use strict'; +const fs=require('fs'),path=require('path'),vm=require('vm'); +const ctx={console,Math,Date,JSON,Object,Array,Set,Map,Number,String,Boolean,RegExp,parseInt,parseFloat}; +ctx.globalThis=ctx; +vm.createContext(ctx); +const dir=path.join(__dirname,'..','js'); +for(const f of ['util.js','data.js','game.js','match.js']){ + vm.runInContext(fs.readFileSync(path.join(dir,f),'utf8'),ctx,{filename:f}); +} +vm.runInContext(` +Game.newWorld('normal'); +Game.startWithTeam('t0'); +G.calendar.forEach(e=>Game.registerEvent(e.id)); +for(let d=0;d<26;d++){ + const fx=Game.advanceOneDay(); + if(fx){console.log('day',G.day,'USER FIXTURE vs',Game.team(fx.a===G.playerTeamId?fx.b:fx.a)?.tag); + MatchEngine.simulate(fx,G.calendar.find(e=>e.id===fx.evId));} + if(G.day>=14){ + const ev=G.calendar[0]; + console.log('day',G.day,'ev0:',ev.name,ev.status,'rounds:',ev.rounds.map(r=>r.name+'['+r.matches.map(m=>(m.a&&m.b?'AB':'..')+(m.winner?'W':'.')+':'+m.day).join(',')+']').join(' ')); + } +} +`,ctx); diff --git a/test/ratings.js b/test/ratings.js new file mode 100644 index 0000000..1049cdd --- /dev/null +++ b/test/ratings.js @@ -0,0 +1,21 @@ +'use strict'; +const fs=require('fs'),path=require('path'),vm=require('vm'); +const ctx={console,Math,Date,JSON,Object,Array,Set,Map,Number,String,Boolean,RegExp,parseInt,parseFloat}; +ctx.globalThis=ctx;vm.createContext(ctx); +const dir=path.join(__dirname,'..','js'); +for(const f of ['util.js','data.js','game.js','match.js'])vm.runInContext(fs.readFileSync(path.join(dir,f),'utf8'),ctx,{filename:f}); +vm.runInContext(` +Game.newWorld('normal'); +// rating distribution over many head-to-head sims between similar teams +const ids=Object.keys(G.teams); +let all=[]; +for(let i=0;i<40;i++){ + const a=ids[i%ids.length], b=ids[(i+7)%ids.length]; + const m={id:'x'+i,a,b,bo:1,day:G.day}; + MatchEngine.simulate(m,null); + Object.entries(m.box).forEach(([pid,st])=>all.push((st.ratingSum||0)/Math.max(st.mapsN||1,1))); +} +all.sort((x,y)=>x-y); +console.log('n='+all.length,'min',all[0].toFixed(2),'p10',all[Math.floor(all.length*.1)].toFixed(2), + 'median',all[Math.floor(all.length/2)].toFixed(2),'p90',all[Math.floor(all.length*.9)].toFixed(2),'max',all.at(-1).toFixed(2)); +`,ctx); diff --git a/test/smoke.js b/test/smoke.js new file mode 100644 index 0000000..1b1092a --- /dev/null +++ b/test/smoke.js @@ -0,0 +1,76 @@ +'use strict'; +/* Headless smoke test: runs the engine without a browser. */ +const fs=require('fs'),path=require('path'),vm=require('vm'); +const ctx={console,Math,Date,JSON,Object,Array,Set,Map,Number,String,Boolean,RegExp,parseInt,parseFloat,isFinite,NaN,Infinity,undefined}; +ctx.globalThis=ctx; +vm.createContext(ctx); +const dir=path.join(__dirname,'..','js'); +for(const f of ['util.js','data.js','game.js','match.js']){ + vm.runInContext(fs.readFileSync(path.join(dir,f),'utf8'),ctx,{filename:f}); +} +const t=(name,cond)=>{if(!cond){console.error('❌ FAIL:',name);process.exitCode=1;}else console.log('✔',name);}; + +let out=vm.runInContext(` +(function(){ + const log=[]; + Game.newWorld('normal'); + log.push('teams='+Object.keys(G.teams).length+' players='+Object.keys(G.players).length+' staffPool='+Object.keys(G.staff).length+' events='+G.calendar.length); + // sanity: every team has 5-man lineup + let badLineup=[...Object.values(G.teams)].filter(t=>t.lineup.length!==5).map(t=>t.name); + log.push('badLineups='+JSON.stringify(badLineup)); + Game.startWithTeam('t0'); // NAVI + const meId=G.playerTeamId; + // register everything + G.calendar.forEach(e=>{const r=Game.registerEvent(e.id);}); + // fast-forward two years + let userMatches=0,eventsDone=0,champs={},errors=[]; + for(let i=0;i<760;i++){ + try{ + const fx=Game.advanceOneDay(); + if(fx){ + userMatches++; + const ev=fx.evId?G.calendar.find(e=>e.id===fx.evId):null; + MatchEngine.simulate(fx,ev); + } + }catch(e){errors.push(i+':'+e.stack.split(String.fromCharCode(10)).slice(0,3).join(' | '));} + } + eventsDone=G.calendar.filter(e=>e.status==='done').length; + G.calendar.forEach(e=>{if(e.champion)champs[e.name]=Game.team(e.champion).tag;}); + const me=Game.team(meId); + log.push('userMatches='+userMatches+' eventsDone='+eventsDone); + log.push('myRank=#'+(G.ranking.indexOf(meId)+1)+' pts='+Math.round(me.ratingPts)+' money='+Math.round(G.money)+' fans='+G.fans+' board='+Math.round(G.board)); + log.push('season='+G.season+' newsCount='+G.news.length); + log.push('champSample='+JSON.stringify(Object.fromEntries(Object.entries(champs).slice(0,6)))); + log.push('errors='+JSON.stringify(errors)); + // stats sanity for star player + const star=me.lineup.map(id=>G.players[id]).filter(Boolean).sort((a,b)=>b.seasonStats.maps-a.seasonStats.maps)[0]; + log.push('star='+(star?star.nick+' maps='+star.seasonStats.maps+' rating='+(star.seasonStats.maps?(star.seasonStats.ratingSum/star.seasonStats.maps).toFixed(2):'n/a'):'none (empty lineup)')); + // transfer mechanics + const faId=G.freeAgents[0]; + G.money=5000000; + const r1=Game.signFreeAgent(faId,wageDemand(Game.p(faId),G)*1.2,3,100000); + log.push('signFA='+r1.ok+':'+r1.msg.slice(0,40)); + // interactive session test vs weakest AI team + const weakest=Object.values(G.teams).filter(x=>x.id!==meId).sort((a,b)=>a.ratingPts-b.ratingPts)[0]; + const fx={id:'testfx',a:meId,b:weakest.id,bo:3,day:G.day}; + const before=me.ratingPts; + const S=SimSession.start(fx,null); + let guard=0; + while(S.phase==='veto'||S.phase==='pregame'){SimSession.vetoAction([...S.veto.pool][0]);if(guard++>20)break;} + SimSession.beginMap(0); + guard=0; + while(S.phase!=='done'&&guard++<800){ + if(S.phase==='intermission')SimSession.beginMap(S.results.length); + else if(S.awaiting==='buy')SimSession.chooseBuy(S.cur.fund[S.userId]>=11800?'full':'eco'); + else if(S.awaiting==='tactic')SimSession.chooseTactic(['aggr','default','patient','fake'][guard%4]); + else if(S.awaiting==='next')SimSession.nextRound(); + } + log.push('simMode: phase='+S.phase+' score='+S.scoreA+'-'+S.scoreB+' maps='+S.results.map(r=>r.map+' '+r.sa+'-'+r.sb).join(', ')); + // save/load round-trip + const snap=Game.serialize(); + Game.staticLoad(snap); + log.push('saveRoundTrip='+(G.playerTeamId===meId&&Object.keys(G.players).length>100?'OK':'BROKEN')); + return log.join('\\n'); +})() +`,ctx); +console.log(out);