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