turfbreak / sim.js
CyberMax-tools's picture
Deploy turfbreak
217da1a verified
Raw History Blame Contribute Delete
39.8 kB
// Turfbreak simulation: operators with real 3D bodies (feet height, gravity, jump, mantling onto cover, crouch,
// slide, sprint, aim-down-sights), hitscan rays in 3D with head and body hitboxes (headshot multipliers), a sidearm
// slot, bots that take cover, crouch behind low walls, peek and shoot only along a real line of sight, charges, zones
// (Turf = Domination), killstreaks (Recon Drone, Sentry, Skyfall Strike), scoring and a 30 Hz history of the whole
// match for kill-cams from the killer's eyes and the "play of the match" replay. Pure and deterministic for a seed +
// inputs (fixed 60 Hz steps), no DOM, no three.js.
// Mature tone, no gore: a tagged-out operator breaks into sparks and redeploys at the crew's spawn.
// (c) 2026 CyberMax. All rights reserved.
import { rng, WEAPONS, WEAPON_IDS, ARMS, PERK_IDS, STREAKS, TEAMS, BODY, collide, collide3, groundAt, mantleTarget, rayHit3, los3, dir3, rayBody, field, steer, inBox } from './core.js';
export const DT = 1 / 60;
export const HS = 13; // floats per operator per history frame
// Movement feel (see FEEL.md): linear ground acceleration and friction, Quake-style air control that keeps momentum,
// a snappy 1.25 m jump with coyote time and a jump buffer, and a sprint-slide you can hop out of.
const GRAV = 20, JUMP = 7.05, WALK = 6.4, ACC_G = 70, FRIC = 64, ACC_A = 22, COYOTE = 0.1, JBUF = 0.12;
const SLIDE_V = 10.8, SLIDE_T = 0.75, SLIDE_FRIC = 5.5, SLIDE_CD = 0.9;
/** Spawn protection (s): no damage, bots ignore you; it ends early the moment you fire. */
export const PROT = 2.0;
const lerp = (a, b, t) => a + (b - a) * t;
const approach = (v, t, step) => (v < t ? Math.min(t, v + step) : Math.max(t, v - step));
const CALLSIGNS = ['RAZOR', 'VANTA', 'NOMAD', 'HEX', 'KILO', 'RUNE', 'SABLE', 'ONYX', 'DRIFT', 'VOLT', 'ASH', 'CIPHER', 'MOTH', 'FLINT', 'JINX', 'NOVA', 'RIFT', 'STATIC', 'WRAITH', 'BISHOP', 'ECHO', 'GRIT', 'HAVOC', 'LYNX'];
const angDiff = (a, b) => { let d = b - a; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return d; };
/** Eye position of an operator (world). */
export const eyeOf = (e) => [e.x, e.y + BODY.eye + (BODY.eyeC - BODY.eye) * e.crouch, e.z];
const chestOf = (e) => [e.x, e.y + 1.05 - 0.32 * e.crouch, e.z];
const headOf = (e) => [e.x, e.y + BODY.head + (BODY.headC - BODY.head) * e.crouch, e.z];
/** The weapon an operator holds right now. */
export const armOf = (e) => ARMS[e.slot === 1 ? 'p9' : e.weapon];
/**
* opts: { map, seed, mode: 'turf'|'showdown', you: {name, weapon, perk, skin}, hard, size (per team), time (s), target,
* skill (0..1, how sharp the bots against you are; 0.5 = normal, hard = 1), rookie (first match ever: one enemy
* walks straight to the middle zone so the new player meets an easy first fight) }
*/
export function createMatch(opts) {
const { map } = opts;
const R = rng((opts.seed || 1) ^ 0x9E3779B9);
const mode = opts.mode || 'turf';
const size = opts.size || (mode === 'showdown' ? 3 : 4);
const skill = opts.hard ? 1 : Math.max(0, Math.min(1, opts.skill != null ? opts.skill : 0.5));
const G = {
map, mode, R, t: 0, tick: 0, over: false, winner: -1, hard: !!opts.hard, skill,
time: opts.time || (mode === 'showdown' ? 60 : 180), left: 0, target: opts.target || 200,
score: [0, 0], rounds: [0, 0], round: 1, roundOver: 0, toWin: 4,
zones: map.zones.map((z) => ({ ...z, owner: -1, prog: 0, contest: false, n: [0, 0] })),
ents: [], charges: [], strikes: [], sentries: [], recon: [0, 0], events: [], hist: [], histEv: [], feed: [],
you: null, lastKill: null, cover: coverSpots(map),
};
G.left = G.time;
const used = new Set(); const names = CALLSIGNS.slice();
const pickName = () => { let n; do { n = names[Math.floor(R() * names.length)]; } while (used.has(n) && used.size < names.length); used.add(n); return n; };
if (opts.you) used.add(opts.you.name);
let id = 0;
for (const team of [0, 1]) for (let k = 0; k < size; k++) {
const isYou = team === 0 && k === 0 && !!opts.you;
const w = isYou ? (opts.you.weapon || 'vk9') : WEAPON_IDS[Math.floor(R() * WEAPON_IDS.length)];
const perk = isYou ? (opts.you.perk || 'plated') : PERK_IDS[Math.floor(R() * PERK_IDS.length)];
const e = {
id: id++, team, bot: !isYou, you: isYou, name: isYou ? opts.you.name : pickName(), skin: isYou ? (opts.you.skin || 0) : Math.floor(R() * 3),
weapon: w, perk, x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, yaw: 0, pitch: 0, crouch: 0, ground: true, sprint: false, ads: 0,
hp: 100, maxHp: 100, alive: true, respawn: 0, spawnT: 0, slot: 0, mags: [WEAPONS[w].mag, ARMS.p9.mag], bloom: 0,
ammo: WEAPONS[w].mag, reload: 0, cd: 0, firing: 0, dash: 0, dashCd: 0, nades: 2, lastHit: -9, lastFire: -9, lastHitBy: -1, trig: false,
streak: 0, bestStreak: 0, kills: 0, deaths: 0, caps: 0, assists: 0, heads: 0, score: 0, dmgBy: {}, owned: [],
ai: { zone: null, target: null, react: 0, err: 0, errP: 0, strafe: 1, strafeT: 0, think: 0, retreat: 0, nadeT: 2 + R() * 4, wander: 0, cover: null, coverT: 0, peekT: 0, lostT: 0, lastSeen: null, burst: 0 },
killer: null, moving: 0, kick: 0, revealT: 0, slide: 0, slideCd: 0, airT: 0, jumpBuf: 0, jumped: false, prot: 0,
// bots against you get the match skill; your own teammates are never worse than normal
skill: !opts.you ? skill : team === 0 ? Math.max(skill, 0.55) : skill,
};
G.ents.push(e);
if (isYou) G.you = e;
}
if (opts.you && opts.rookie) { // first match: the nearest enemy heads straight for B, a little slower on the trigger
const r = G.ents.find((e) => e.team === 1); r.skill = Math.min(r.skill, 0.05); r.ai.zone = G.zones[1]; r.ai.think = 9; r.rookie = true;
}
for (const e of G.ents) spawn(G, e, true);
return G;
}
/** Candidate cover spots: points hugging every box tall enough to hide a crouched body, on its four sides. */
function coverSpots(map) {
const out = [];
for (const o of map.obstacles) {
if (o.h < 1.0 || Math.abs(o.x) > 46 || Math.abs(o.z) > 50 || o.kind === 'wall' || o.kind === 'lamp') continue;
const low = o.h < 1.7;
const pts = [];
for (const s of [-1, 1]) {
for (const f of (o.w > 4 ? [-0.3, 0.3] : [0])) pts.push([o.x + f * o.w, o.z + s * (o.d / 2 + 0.75)]);
for (const f of (o.d > 4 ? [-0.3, 0.3] : [0])) pts.push([o.x + s * (o.w / 2 + 0.75), o.z + f * o.d]);
}
for (const [x, z] of pts) {
if (Math.abs(x) > 45 || Math.abs(z) > 49) continue;
if (map.obstacles.some((q) => inBox(q, x, z, 0.5))) continue;
out.push({ x, z, low, o });
}
}
return out;
}
function spawn(G, e, first) {
const pts = G.map.spawns[e.team]; const k = G.ents.filter((o) => o.team === e.team).indexOf(e);
let p = pts[k % pts.length];
if (!first) { // redeploy at the spawn point farthest from every living enemy (no spawn-camping)
let best = -1;
for (const q of pts) { let near = 1e9; for (const o of G.ents) if (o.team !== e.team && o.alive) near = Math.min(near, Math.hypot(o.x - q.x, o.z - q.z)); near += G.R() * 3; if (near > best) { best = near; p = q; } }
}
e.x = p.x + (G.R() - 0.5) * 2; e.z = p.z + (G.R() - 0.5) * 1.5; e.yaw = p.yaw; e.pitch = 0; e.vx = e.vz = e.vy = 0; e.y = 0; e.ground = true; e.crouch = 0;
e.maxHp = e.perk === 'plated' ? 130 : 100; e.hp = e.maxHp; e.alive = true; e.spawnT = G.t; e.prot = G.t + PROT; e.dmgBy = {};
e.slot = 0; e.mags = [WEAPONS[e.weapon].mag, ARMS.p9.mag]; e.ammo = e.mags[0]; e.reload = 0; e.cd = 0.3; e.nades = 2; e.streak = 0; e.dash = 0; e.dashCd = 0; e.bloom = 0; e.ads = 0;
e.slide = 0; e.slideCd = 0; e.airT = 0; e.jumpBuf = 0; e.jumped = false;
const keepZone = first && e.rookie;
e.ai.target = null; e.ai.react = 0; if (!keepZone) { e.ai.zone = null; e.ai.think = 0; } e.ai.cover = null; e.ai.lastSeen = null;
}
const ev = (G, e) => { e.tick = G.tick; G.events.push(e); G.histEv.push(e); };
/** Can `m` see operator `o`? (eye to head or chest, real 3D line of sight) */
function sees(G, m, o) {
const a = eyeOf(m); const h = headOf(o); if (los3(G.map, a[0], a[1], a[2], h[0], h[1], h[2])) return 'head';
const c = chestOf(o); if (los3(G.map, a[0], a[1], a[2], c[0], c[1], c[2])) return 'chest';
return null;
}
export const canSee = (G, m, o) => !!sees(G, m, o);
/** Is enemy `o` visible to team `team`? (line of sight from any alive teammate, a fresh shot, or the recon drone) */
export function spotted(G, team, o) {
if (!o.alive) return false;
if (G.recon[team] > 0 && o.perk !== 'ghost') return true;
if (G.t - o.lastFire < 0.7) return true;
for (const m of G.ents) if (m.team === team && m.alive && Math.hypot(m.x - o.x, m.z - o.z) < 48 && sees(G, m, o)) return true;
return false;
}
/** Current spread (radians, half-angle) of an operator's weapon: hip vs aimed, moving, airborne, crouched, bloom. */
export function spreadOf(e) {
const W = armOf(e); const mv = Math.hypot(e.vx, e.vz);
if (W.id === 'breach') return W.spread * (1 - 0.35 * e.ads) + e.bloom;
// the first aimed shot from a steady stance goes exactly where the crosshair / dot is
if (e.ads > 0.95 && mv < 1 && e.ground && e.bloom < 0.002) return 0;
const s = W.spread * (1 - 0.88 * e.ads) * (mv > 1 ? 1 + 0.6 * Math.min(1, mv / WALK) : 0.8) * (e.ground ? 1 : 2.2) * (1 - 0.22 * e.crouch);
return s + e.bloom * (1 - 0.5 * e.ads);
}
/** What the operator's crosshair is on: { ent, dist, head } for the first enemy along the exact view ray, else null. */
export function aimTarget(G, e, maxD = 80) {
const o = eyeOf(e); const d = dir3(e.yaw, e.pitch);
const wall = rayHit3(G.map, o[0], o[1], o[2], o[0] + d[0] * maxD, o[1] + d[1] * maxD, o[2] + d[2] * maxD).f * maxD;
let best = null;
for (const q of G.ents) { if (q.team === e.team || !q.alive) continue; const h = rayBody(o, d, q.x, q.y, q.z, q.crouch); if (h && h.t < wall && (!best || h.t < best.dist)) best = { ent: q, dist: h.t, head: h.head }; }
return best;
}
// ---------------------------------------------------------------- shooting
function fire(G, e) {
const W = armOf(e);
e.cd = W.cd; e.ammo -= 1; e.mags[e.slot] = e.ammo; e.lastFire = G.t; e.firing = 0.08; e.kick = W.kick; e.prot = 0; // firing ends spawn protection
const o = eyeOf(e); const sp = spreadOf(e);
for (let p = 0; p < W.pellets; p++) {
// spread: uniform in a cone; bots add their aim error
const a = G.R() * Math.PI * 2, r = Math.sqrt(G.R()) * sp;
const yaw = e.yaw + Math.cos(a) * r + (e.bot ? e.ai.err : 0), pitch = e.pitch + Math.sin(a) * r + (e.bot ? e.ai.errP : 0);
const d = dir3(yaw, pitch); const L = W.range * 2.4;
const wh = rayHit3(G.map, o[0], o[1], o[2], o[0] + d[0] * L, o[1] + d[1] * L, o[2] + d[2] * L); let best = wh.f * L; let hit = null, head = false;
for (const q of G.ents) {
if (q.team === e.team || !q.alive) continue;
const h = rayBody(o, d, q.x, q.y, q.z, q.crouch);
if (h && h.t < best) { best = h.t; hit = q; head = h.head; }
}
let hitS = null;
for (const s of G.sentries) {
if (s.team === e.team || s.hp <= 0) continue;
const h = rayBody(o, d, s.x, 0, s.z, 0.55); if (h && h.t < best) { best = h.t; hit = null; head = false; hitS = s; }
}
const x1 = o[0] + d[0] * best, y1 = o[1] + d[1] * best, z1 = o[2] + d[2] * best;
ev(G, { k: 'shot', from: e.id, w: W.id, x0: o[0], y0: o[1] - 0.12, z0: o[2], x1, y1, z1, hit: hit ? hit.id : hitS ? -2 : -1, hs: head, nx: hit || hitS ? 0 : wh.nx, ny: hit || hitS ? 0 : wh.ny, nz: hit || hitS ? 0 : wh.nz, team: e.team, world: !hit && !hitS && wh.f < 1 });
if (hit) {
const fall = W.id === 'breach' ? Math.max(0.3, 1 - best / W.range) : best > W.range ? 0.72 : best > W.range * 0.7 ? 0.86 : 1;
// bots ease off a human while the match skill is low (new players), full damage from skill 1
const easy = e.bot && hit.you ? lerp(0.55, 1, Math.min(1, e.skill * 1.6)) : 1;
damage(G, hit, W.dmg * fall * (head ? W.hs : 1) * easy, e, W.id, head, [x1, y1, z1]);
} else if (hitS) { hitS.hp -= W.dmg; if (hitS.hp <= 0) ev(G, { k: 'boom', x: hitS.x, y: 0.8, z: hitS.z, r: 2, small: true }); }
}
e.bloom = Math.min(W.spread * 2.5 + 0.03, e.bloom + W.bloom);
if (e.ammo <= 0) startReload(e);
}
function startReload(e) { const W = armOf(e); if (e.reload > 0 || e.ammo >= W.mag) return; e.reload = W.reload * (e.perk === 'quick' ? 0.7 : 1); e.reloadMax = e.reload; }
function damage(G, o, dmg, by, weapon, head = false, at = null) {
if (!o.alive || G.t < o.prot) return; // spawn protection
o.hp -= dmg; o.lastHit = G.t; o.lastHitBy = by.id; o.dmgBy[by.id] = (o.dmgBy[by.id] || 0) + dmg;
ev(G, { k: 'hit', id: o.id, by: by.id, dmg, x: o.x, z: o.z, y: at ? at[1] : o.y + 1, hx: at ? at[0] : o.x, hz: at ? at[2] : o.z, fx: by.x, fz: by.z, hs: head, you: by.you });
if (o.bot && by.team !== o.team && (o.ai.target == null || G.ents[o.ai.target] && !G.ents[o.ai.target].alive)) { o.ai.target = by.id; o.ai.react = lerp(0.6, 0.2, o.skill); o.ai.lastSeen = { x: by.x, z: by.z, t: G.t }; }
if (o.hp <= 0) kill(G, o, by, weapon, head);
}
function kill(G, o, by, weapon, head = false) {
o.alive = false; o.hp = 0; o.deaths++; o.streak = 0; o.respawn = G.mode === 'showdown' ? 1e9 : 4.2; o.killer = by.id;
by.kills++; by.streak++; by.bestStreak = Math.max(by.bestStreak, by.streak); by.score += 100;
if (head) { by.heads++; by.score += 25; }
const inZone = G.zones.find((z) => Math.hypot(o.x - z.x, o.z - z.z) < z.r + 1 && z.owner === by.team);
if (inZone) by.score += 50;
for (const [aid, d] of Object.entries(o.dmgBy)) { const a = G.ents[aid]; if (a && a !== by && a.team === by.team && d >= 35) { a.assists++; a.score += 50; } }
const recent = G.feed.filter((f) => f.by === by.id && G.t - f.t < 4).length;
const multi = recent + 1;
if (multi >= 2) by.score += 50 * (multi - 1);
G.feed.push({ t: G.t, by: by.id, victim: o.id, w: weapon, multi, hs: head });
G.lastKill = { tick: G.tick, by: by.id, victim: o.id, w: weapon, multi, team: by.team, hs: head };
ev(G, { k: 'kill', by: by.id, victim: o.id, w: weapon, x: o.x, y: o.y, z: o.z, multi, streak: by.streak, team: by.team, hs: head, fx: by.x, fz: by.z });
// killstreaks (per life)
const s = STREAKS.find((k) => k.at === by.streak);
if (s && by.alive) { by.owned.push(s.id); ev(G, { k: 'streak', id: by.id, s: s.id }); if (by.bot || s.id === 'recon') useStreak(G, by, s.id); }
}
// ---------------------------------------------------------------- killstreaks
export function useStreak(G, e, id = null) {
const sid = id || e.owned[e.owned.length - 1]; if (!sid) return false;
const i = e.owned.lastIndexOf(sid); if (i < 0) return false; e.owned.splice(i, 1);
if (sid === 'recon') { G.recon[e.team] = 12; ev(G, { k: 'recon', team: e.team, id: e.id }); }
if (sid === 'sentry') { const s = { team: e.team, by: e.id, x: e.x + Math.cos(e.yaw) * 1.2, z: e.z + Math.sin(e.yaw) * 1.2, yaw: e.yaw, hp: 140, life: 22, cd: 0, firing: 0 }; [s.x, s.z] = collide(G.map, s.x, s.z, 0.7); G.sentries.push(s); ev(G, { k: 'sentry', team: e.team, x: s.x, z: s.z, id: e.id }); }
if (sid === 'skyfall') {
let a = e.yaw;
if (e.bot) { // aim at the crowd
let best = 0; for (const o of G.ents) if (o.team !== e.team && o.alive) { const n = G.ents.filter((p) => p.team === o.team && p.alive && Math.hypot(p.x - o.x, p.z - o.z) < 8).length; if (n > best) { best = n; a = Math.atan2(o.z - e.z, o.x - e.x); } }
}
const pts = []; for (let k = 0; k < 7; k++) { const d = 7 + k * 4.4; pts.push({ x: e.x + Math.cos(a) * d, z: e.z + Math.sin(a) * d, t: 1.6 + k * 0.17 }); }
G.strikes.push({ team: e.team, by: e.id, pts, t: 0 });
ev(G, { k: 'skyfall', team: e.team, id: e.id, pts: pts.map((p) => [p.x, p.z]) });
}
return true;
}
function explode(G, x, z, r, dmg, by, weapon, y = 0.3) {
ev(G, { k: 'boom', x, y, z, r, by: by ? by.id : -1, w: weapon });
for (const o of G.ents) {
if (!o.alive || (by && o.team === by.team && o !== by)) continue;
const d = Math.hypot(o.x - x, o.z - z, (o.y + 0.9 - y) * 0.6); if (d > r) continue;
if (!los3(G.map, x, y + 0.4, z, o.x, o.y + 1.0, o.z) && !los3(G.map, x, y + 0.4, z, o.x, o.y + 1.5, o.z)) continue;
damage(G, o, dmg * (1 - (d / r) * 0.6) * (o === by ? 0.4 : 1), by, weapon);
}
}
// ---------------------------------------------------------------- bots
const PREF = { breach: 6, lancer: 24, wasp: 10, vk9: 15 };
function think(G, e) {
const A = e.ai;
// target: an enemy this bot can actually see (3D line of sight, in its field of view or very close / just fired)
let tgt = A.target != null ? G.ents[A.target] : null;
if (tgt && (!tgt.alive || G.t < tgt.prot || Math.hypot(tgt.x - e.x, tgt.z - e.z) > 52)) { tgt = null; A.target = null; }
if (tgt) { if (sees(G, e, tgt)) { A.lastSeen = { x: tgt.x, z: tgt.z, t: G.t }; A.lostT = 0; } else { A.lostT += 1 / 15; if (A.lostT > 1.4) { tgt = null; A.target = null; } } }
if (!tgt) {
let bd = 44;
for (const o of G.ents) {
if (o.team === e.team || !o.alive || G.t < o.prot) continue; const d = Math.hypot(o.x - e.x, o.z - e.z);
if (d >= bd) continue;
const fov = Math.abs(angDiff(e.yaw, Math.atan2(o.z - e.z, o.x - e.x))) < 1.25;
const noticed = G.recon[e.team] > 0 || fov || d < 7 || G.t - o.lastFire < 0.6;
if (noticed && sees(G, e, o)) { bd = d; tgt = o; }
}
if (tgt) {
A.target = tgt.id; A.lastSeen = { x: tgt.x, z: tgt.z, t: G.t }; A.lostT = 0;
// reaction time and first-aim error follow the bot's skill (0 = rookie lobby, 0.5 = normal, 1 = Veteran)
const k = e.skill;
A.react = lerp(0.82, 0.22, k) + G.R() * lerp(0.3, 0.1, k) + (tgt.crouch > 0.5 ? 0.1 : 0) + (tgt.you && k < 0.3 ? 0.12 : 0);
A.err = (G.R() - 0.5) * lerp(0.46, 0.14, k); A.errP = (G.R() - 0.5) * lerp(0.22, 0.07, k);
}
}
// objective: a zone we don't hold (nearest), sometimes hold one we do
if (!A.zone || A.think <= 0) {
A.think = 2 + G.R() * 2;
const zs = G.zones.slice().sort((a, b) => Math.hypot(a.x - e.x, a.z - e.z) - Math.hypot(b.x - e.x, b.z - e.z));
const want = zs.filter((z) => z.owner !== e.team);
A.zone = G.mode === 'showdown' ? G.zones[1] : (want.length && G.R() < 0.82 ? want[Math.min(want.length - 1, G.R() < 0.7 ? 0 : 1)] : zs[Math.floor(G.R() * zs.length)]);
A.wander = G.R() * Math.PI * 2;
}
return tgt;
}
/** The nearest cover spot that blocks the threat's view of a (crouched) body there, and that we can walk to. */
function findCover(G, e, th) {
let best = null, bd = 13;
const te = eyeOf(th);
for (const c of G.cover) {
const d = Math.hypot(c.x - e.x, c.z - e.z); if (d > bd) continue;
// the box must sit between the spot and the threat
if ((c.o.x - c.x) * (th.x - c.x) + (c.o.z - c.z) * (th.z - c.z) <= 0) continue;
const y = groundAt(G.map, c.x, c.z, 0);
if (los3(G.map, te[0], te[1], te[2], c.x, y + (c.low ? 0.95 : 1.4), c.z)) continue;
if (G.ents.some((m) => m !== e && m.alive && m.ai.cover === c)) continue;
bd = d; best = c;
}
return best;
}
function botStep(G, e, dt) {
const A = e.ai; const W = armOf(e);
A.think -= dt; A.strafeT -= dt; A.nadeT -= dt; A.coverT -= dt; A.peekT -= dt;
const tgt = (G.tick + e.id) % 4 === 0 || A.target == null ? think(G, e) : (A.target != null ? G.ents[A.target] : null);
let mx = 0, mz = 0, fire = false, crouch = false, jump = false;
const hurt = e.hp < e.maxHp * 0.45 && G.t - e.lastHit < 2.5;
// switch to the sidearm when the main weapon is empty in a fight at close range, back when safe
if (tgt && e.slot === 0 && e.ammo === 0 && Math.hypot(tgt.x - e.x, tgt.z - e.z) < 12 && e.mags[1] > 0) setSlot(e, 1);
else if (!tgt && e.slot === 1 && e.reload <= 0) setSlot(e, 0);
// want cover? (hurt, reloading or under fire from a target we can't beat in the open)
if (tgt && !A.cover && A.coverT <= 0 && (hurt || e.reload > 0 || (G.t - e.lastHit < 0.6 && G.R() < 0.35))) {
A.cover = findCover(G, e, tgt); A.coverT = A.cover ? 4 + G.R() * 2 : 1.5; A.peekT = 1.2 + G.R() * 1.4;
}
if (A.cover && (A.coverT <= 0 || !tgt && G.R() < 0.01)) { A.cover = null; A.coverT = 2.5 + G.R() * 2; }
let lookAt = null;
if (tgt) {
const seen = A.lostT === 0;
const aimP = seen ? (sees(G, e, tgt) === 'head' && G.R() < 0.4 ? headOf(tgt) : chestOf(tgt)) : [A.lastSeen.x, 1.2, A.lastSeen.z];
const dx = aimP[0] - e.x, dz = aimP[2] - e.z; const d = Math.hypot(dx, dz);
const eye = eyeOf(e); const wantY = Math.atan2(dz, dx), wantP = Math.atan2(aimP[1] - eye[1], d);
const k = Math.min(1, dt * lerp(4.5, 13, e.skill)); const settle = lerp(0.45, 1.7, e.skill);
e.yaw += angDiff(e.yaw, wantY + A.err) * k; e.pitch += (wantP + A.errP - e.pitch) * k;
A.err *= Math.exp(-dt * settle); A.errP *= Math.exp(-dt * settle);
A.react -= dt;
const onTarget = Math.abs(angDiff(e.yaw, wantY)) < 0.22 && Math.abs(e.pitch - wantP) < 0.2;
// fire in bursts (auto weapons) when it can see the target
if (A.react <= 0 && seen && d < W.range * 1.5 && onTarget) {
if (A.burst <= 0) A.burst = W.auto ? 0.35 + G.R() * 0.5 : 0.15; fire = true;
}
if (A.burst > 0) { A.burst -= dt; if (A.burst <= 0) { A.react = W.auto ? 0.12 + G.R() * 0.22 : 0.05; } }
if (A.cover) {
// move to cover, crouch behind low cover, reload there, then peek out (stand / strafe) to shoot
const c = A.cover; const cd = Math.hypot(c.x - e.x, c.z - e.z);
if (cd > 0.6) { const f = field(G.map.nav, c.x, c.z); const [sx, sz] = steer(G.map.nav, f, e.x, e.z); if (sx || sz) { mx = sx; mz = sz; } else { mx = (c.x - e.x) / cd; mz = (c.z - e.z) / cd; } fire = fire && cd < 4; }
else {
const peeking = A.peekT < 0 && A.peekT > -1.1 && e.reload <= 0 && !hurt;
if (A.peekT < -1.1) A.peekT = 0.9 + G.R() * 1.6;
if (c.low) crouch = !peeking; else if (peeking) { const ux = dx / (d || 1), uz = dz / (d || 1); mx = -uz * A.strafe * 0.9; mz = ux * A.strafe * 0.9; }
if (!peeking) fire = false;
if (e.reload <= 0 && e.ammo < W.mag * 0.5 && !peeking) startReload(e);
}
} else {
if (A.strafeT <= 0) { A.strafe = G.R() < 0.5 ? -1 : 1; A.strafeT = 0.5 + G.R() * 1.0; if (G.R() < 0.12 && e.ground) jump = true; if (G.R() < 0.2) A.crouchT = 0.6; }
const pref = PREF[W.id] || 12;
const toward = !seen ? 1 : d > pref + 3 ? 1 : d < pref - 4 ? -0.8 : 0;
const ux = dx / (d || 1), uz = dz / (d || 1);
if (seen) { mx = ux * toward + -uz * A.strafe * 0.85; mz = uz * toward + ux * A.strafe * 0.85; }
else { const f = field(G.map.nav, A.lastSeen.x, A.lastSeen.z); const [sx, sz] = steer(G.map.nav, f, e.x, e.z); mx = sx; mz = sz; }
if ((A.crouchT || 0) > 0) { A.crouchT -= dt; crouch = true; }
if (A.retreat > 0) { mx = -ux; mz = -uz; }
}
// charge at a target hiding behind cover
if (A.nadeT <= 0 && e.nades > 0 && d > 7 && d < 15 && (!seen || tgt.crouch > 0.5) && G.R() < 0.5) { throwCharge(G, e, tgt.x + (G.R() - 0.5) * 2, tgt.z + (G.R() - 0.5) * 2); A.nadeT = 6 + G.R() * 8; }
if (G.t - e.lastHit < 0.3 && e.dashCd <= 0 && G.R() < 0.06 && !A.cover) e.dash = 0.3;
if (e.reload <= 0 && e.ammo === 0) startReload(e);
} else {
if (e.reload <= 0 && e.ammo < armOf(e).mag * 0.6) startReload(e);
const z = A.zone; let gx = z.x, gz = z.z;
const inZone = Math.hypot(e.x - z.x, e.z - z.z) < z.r - 0.5;
if (inZone) { A.wander += dt * 0.6; gx = z.x + Math.cos(A.wander + e.id) * z.r * 0.5; gz = z.z + Math.sin(A.wander + e.id) * z.r * 0.5; }
if (A.retreat > 0) { const sp = G.map.spawns[e.team][0]; gx = sp.x; gz = sp.z; }
// heard a shot or got hit from somewhere: turn to look
if (G.t - e.lastHit < 1.2 && e.lastHitBy >= 0) { const b = G.ents[e.lastHitBy]; lookAt = [b.x, b.z]; }
const f = field(G.map.nav, gx, gz); const [sx, sz] = steer(G.map.nav, f, e.x, e.z);
const dd = Math.hypot(gx - e.x, gz - e.z);
if (sx || sz) { mx = sx; mz = sz; } else if (dd > 0.6) { mx = (gx - e.x) / dd; mz = (gz - e.z) / dd; }
const la = lookAt ? Math.atan2(lookAt[1] - e.z, lookAt[0] - e.x) : (mx || mz) ? Math.atan2(mz, mx) : e.yaw;
e.yaw += angDiff(e.yaw, la) * Math.min(1, dt * 6); e.pitch += (0 - e.pitch) * Math.min(1, dt * 4);
}
if (e.hp < e.maxHp * 0.3 && G.t - e.lastHit < 2 && G.R() < 0.02 && !A.cover) A.retreat = 2.5;
A.retreat -= dt;
return { mx, mz, fire, crouch, jump };
}
function setSlot(e, s) { if (e.slot === s) return; e.mags[e.slot] = e.ammo; e.slot = s; e.ammo = e.mags[s]; e.reload = 0; e.cd = Math.max(e.cd, 0.32); e.bloom = 0; }
// ---------------------------------------------------------------- charges
function throwCharge(G, e, tx, tz) {
if (e.nades <= 0) return; e.nades--;
let dx = tx - e.x, dz = tz - e.z; const d = Math.hypot(dx, dz) || 1; const L = Math.min(16, d); dx /= d; dz /= d;
// stop at walls (anything taller than a thrown arc clears)
const f = rayHit3(G.map, e.x, e.y + 1.6, e.z, e.x + dx * L, e.y + 1.6, e.z + dz * L).f; const land = Math.max(1, L * f - 0.6);
const lx = e.x + dx * land, lz = e.z + dz * land;
G.charges.push({ by: e.id, team: e.team, x0: e.x, z0: e.z, y0: e.y + 1.5, x: lx, z: lz, y: groundAt(G.map, lx, lz, 9, 0.1), t: 0, fuse: 1.15, fly: 0.55 });
ev(G, { k: 'throw', id: e.id });
}
// ---------------------------------------------------------------- the step
/**
* input (for your operator): { mx, mz (world move, |m| <= 1), yaw, pitch (absolute view), fire, ads, sprint, crouch,
* jump, slot (0 main | 1 sidearm), dash, nade, streak, reload }. Legacy top-down aim (ax, az) still works.
*/
export function step(G, input = {}) {
const dt = DT; G.events.length = 0; if (G.over) return G;
G.t += dt; G.tick++;
if (G.roundOver > 0) { G.roundOver -= dt; if (G.roundOver <= 0) nextRound(G); record(G); return G; }
G.left = Math.max(0, G.left - dt);
G.recon[0] = Math.max(0, G.recon[0] - dt); G.recon[1] = Math.max(0, G.recon[1] - dt);
for (const e of G.ents) {
e.firing = Math.max(0, e.firing - dt); e.kick = Math.max(0, e.kick - dt * 6);
if (!e.alive) { e.respawn -= dt; if (e.respawn <= 0 && G.mode === 'turf') { spawn(G, e); ev(G, { k: 'spawn', id: e.id }); } continue; }
let mx = 0, mz = 0, wantFire = false, ads = false, sprint = false, crouch = false, jump = false, slide = false;
if (e.bot) { const b = botStep(G, e, dt); mx = b.mx; mz = b.mz; wantFire = b.fire; crouch = b.crouch; jump = b.jump; ads = b.fire && armOf(e).id === 'lancer'; }
else {
mx = input.mx || 0; mz = input.mz || 0; const l = Math.hypot(mx, mz); if (l > 1) { mx /= l; mz /= l; }
if (input.ax != null) e.yaw = Math.atan2(input.az - e.z, input.ax - e.x); else if (input.yaw != null) e.yaw = input.yaw;
if (input.pitch != null) e.pitch = Math.max(-1.45, Math.min(1.45, input.pitch));
wantFire = !!input.fire; ads = !!input.ads; sprint = !!input.sprint; crouch = !!input.crouch; jump = !!input.jump; slide = !!input.slide;
if (input.slot != null) setSlot(e, input.slot ? 1 : 0);
if (input.dash && e.dashCd <= 0) e.dash = 0.32;
if (input.nade && e.nades > 0 && G.charges.filter((c) => c.by === e.id).length === 0) {
if (input.ax != null) throwCharge(G, e, input.ax, input.az);
else { const L = Math.max(4, Math.min(16, 9 + e.pitch * 18)); throwCharge(G, e, e.x + Math.cos(e.yaw) * L, e.z + Math.sin(e.yaw) * L); }
}
if (input.streak) useStreak(G, e);
if (input.reload) startReload(e);
}
const W = armOf(e);
// slide: sprint (or run fast) + crouch on the ground -> a burst of speed that glides out over 0.75 s
const hv = Math.hypot(e.vx, e.vz);
if (slide && e.ground && e.slideCd <= 0 && e.slide <= 0 && hv > 4.5) {
const ux = e.vx / hv, uz = e.vz / hv; const v0 = Math.max(hv, SLIDE_V * W.speed);
e.vx = ux * v0; e.vz = uz * v0; e.slide = SLIDE_T; e.slideCd = SLIDE_CD + SLIDE_T; ev(G, { k: 'slide', id: e.id });
}
e.slideCd = Math.max(0, e.slideCd - dt);
// stance (linear, so crouch and aim-down-sights take a fixed, predictable time)
e.crouch = approach(e.crouch, crouch || e.dash > 0 || e.slide > 0 ? 1 : 0, dt / 0.11);
e.ads = approach(e.ads, ads && e.reload <= 0 && e.dash <= 0 && e.slide <= 0 ? 1 : 0, dt / (W.adsT || 0.16));
// sprint only forward-ish, never while firing or aiming
const fwd = mx * Math.cos(e.yaw) + mz * Math.sin(e.yaw);
e.sprint = sprint && fwd > 0.5 && !wantFire && e.ads < 0.3 && e.crouch < 0.5 && e.slide <= 0;
// movement
let sp = WALK * W.speed * (wantFire && W.id === 'lancer' ? 0.8 : 1) * (e.sprint ? 1.34 : 1) * (1 - 0.45 * e.crouch) * (1 - 0.3 * e.ads);
if (e.dash > 0) { if (e.dashCd <= 0) { e.dashCd = e.perk === 'quick' ? 2.2 : 3.2; ev(G, { k: 'dash', id: e.id }); } e.dash -= dt; sp = 13.5; if (!(mx || mz)) { mx = Math.cos(e.yaw); mz = Math.sin(e.yaw); } }
e.dashCd = Math.max(0, e.dashCd - dt);
const tvx = mx * sp, tvz = mz * sp;
if (e.dash > 0) { const a = Math.min(1, 40 * dt); e.vx += (tvx - e.vx) * a; e.vz += (tvz - e.vz) * a; }
else if (e.slide > 0 && e.ground) { // glide: low friction, a little steering, never faster
e.slide -= dt; const v = Math.hypot(e.vx, e.vz); const nv = Math.max(0, v - SLIDE_FRIC * dt);
if (mx || mz) { e.vx += mx * 9 * dt; e.vz += mz * 9 * dt; }
const v2 = Math.hypot(e.vx, e.vz) || 1; e.vx *= nv / v2; e.vz *= nv / v2;
if (nv < 3.2) e.slide = 0;
} else if (e.ground) { // linear acceleration towards the wished velocity (friction when there is no input)
let dvx = tvx - e.vx, dvz = tvz - e.vz; const dl = Math.hypot(dvx, dvz); const step = (mx || mz ? ACC_G : FRIC) * dt;
if (dl > step) { dvx *= step / dl; dvz *= step / dl; } e.vx += dvx; e.vz += dvz;
} else if (mx || mz) { // air control: steer freely, keep the momentum you had (slide-hops stay fast)
const cur = Math.hypot(e.vx, e.vz); e.vx += mx * ACC_A * dt; e.vz += mz * ACC_A * dt;
const cap = Math.max(cur, sp), now = Math.hypot(e.vx, e.vz); if (now > cap) { e.vx *= cap / now; e.vz *= cap / now; }
}
if (e.slide > 0 && !e.ground) e.slide = Math.max(0, e.slide - dt);
// jump (with coyote time after leaving a ledge and a short buffer before landing), gravity, mantle, steps
e.airT = e.ground ? 0 : e.airT + dt; if (jump) e.jumpBuf = JBUF; else e.jumpBuf = Math.max(0, e.jumpBuf - dt);
const canJump = (e.ground || (e.airT < COYOTE && e.vy <= 0 && !e.jumped)) && (e.crouch < 0.6 || e.slide > 0);
let jumpedNow = false;
if (e.jumpBuf > 0 && canJump) { e.vy = JUMP; e.ground = false; e.jumped = true; e.jumpBuf = 0; e.slide = 0; jumpedNow = true; ev(G, { k: 'jump', id: e.id }); }
const nx = e.x + e.vx * dt, nz = e.z + e.vz * dt;
if (!e.ground || jumpedNow) {
const m = mantleTarget(G.map, nx, nz, e.y + 0.05);
if (m && e.vy < 3.2 && (mx || mz) && (Math.hypot(mx, mz) > 0.3)) { e.y = m.h; e.vy = 0; e.ground = true; e.jumped = false; ev(G, { k: 'mantle', id: e.id }); }
}
[e.x, e.z] = collide3(G.map, nx, nz, e.y, 0.45);
// the part of the velocity pointing into what we hit is removed (the push direction is the contact normal); the
// part along the surface is kept, so you slide along walls and around poles and corners instead of sticking
{ const px = e.x - nx, pz = e.z - nz, pl = Math.hypot(px, pz);
if (pl > 1e-7) { const ux = px / pl, uz = pz / pl; const vn = e.vx * ux + e.vz * uz; if (vn < 0) { e.vx -= vn * ux; e.vz -= vn * uz; } } }
const g = groundAt(G.map, e.x, e.z, e.y);
if (e.ground && e.y > g + 0.01) { if (e.y - g <= BODY.step + 0.01 && e.vy <= 0) e.y = g; else e.ground = false; }
if (!e.ground) { e.vy -= GRAV * dt; e.y += e.vy * dt; if (e.y <= g) { if (e.vy < -7.4) ev(G, { k: 'land', id: e.id, v: e.vy }); e.y = g; e.vy = 0; e.ground = true; e.jumped = false; } }
else if (e.y < g) e.y = g;
e.moving = Math.hypot(e.vx, e.vz);
// weapon
e.cd -= dt; e.bloom = Math.max(0, e.bloom - dt * (W.bloom * 9 + 0.02));
const trigger = wantFire && (W.auto || !e.trig); e.trig = wantFire;
if (e.reload > 0) { e.reload -= dt; if (e.reload <= 0) { e.ammo = W.mag; e.mags[e.slot] = e.ammo; ev(G, { k: 'reloaded', id: e.id }); } }
else if (trigger && e.cd <= 0 && e.ammo > 0 && G.t - e.spawnT > 0.3 && !e.sprint) fire(G, e);
else if (wantFire && e.ammo <= 0 && e.reload <= 0) startReload(e);
// regen
if (G.t - e.lastHit > 3.6 && e.hp < e.maxHp) e.hp = Math.min(e.maxHp, e.hp + 34 * dt);
}
// charges
for (const c of G.charges) { c.t += dt; if (c.t >= c.fuse && !c.done) { c.done = true; explode(G, c.x, c.z, 4.4, 96, G.ents[c.by], 'charge', (c.y || 0) + 0.2); } }
G.charges = G.charges.filter((c) => !c.done);
// skyfall
for (const s of G.strikes) { s.t += dt; for (const p of s.pts) if (!p.done && s.t >= p.t) { p.done = true; explode(G, p.x, p.z, 4.8, 130, G.ents[s.by], 'skyfall'); } }
G.strikes = G.strikes.filter((s) => s.pts.some((p) => !p.done));
// sentries
for (const s of G.sentries) {
s.life -= dt; s.cd -= dt; s.firing = Math.max(0, s.firing - dt); if (s.hp <= 0 || s.life <= 0) continue;
let tgt = null, bd = 20;
for (const o of G.ents) if (o.team !== s.team && o.alive) { const d = Math.hypot(o.x - s.x, o.z - s.z); const c = chestOf(o); if (d < bd && los3(G.map, s.x, 0.9, s.z, c[0], c[1], c[2])) { bd = d; tgt = o; } }
if (tgt) {
const want = Math.atan2(tgt.z - s.z, tgt.x - s.x); s.yaw += angDiff(s.yaw, want) * Math.min(1, dt * 8);
if (s.cd <= 0 && Math.abs(angDiff(s.yaw, want)) < 0.2) {
s.cd = 0.13; s.firing = 0.06; const c = chestOf(tgt); const p = Math.atan2(c[1] - 0.9, bd);
const d = dir3(s.yaw + (G.R() - 0.5) * 0.08, p + (G.R() - 0.5) * 0.06); const o = [s.x + d[0] * 0.6, 0.9, s.z + d[2] * 0.6];
let best = 22 * rayHit3(G.map, o[0], o[1], o[2], o[0] + d[0] * 22, o[1] + d[1] * 22, o[2] + d[2] * 22).f; let hit = null;
for (const q of G.ents) { if (q.team === s.team || !q.alive) continue; const h = rayBody(o, d, q.x, q.y, q.z, q.crouch); if (h && h.t < best) { best = h.t; hit = q; } }
ev(G, { k: 'shot', from: -1, w: 'sentry', x0: o[0], y0: o[1], z0: o[2], x1: o[0] + d[0] * best, y1: o[1] + d[1] * best, z1: o[2] + d[2] * best, hit: hit ? hit.id : -1, team: s.team, nx: 0, ny: 0, nz: 0 });
if (hit) damage(G, hit, 10, G.ents[s.by], 'sentry');
}
}
}
G.sentries = G.sentries.filter((s) => s.hp > 0 && s.life > 0);
// zones
for (const z of G.zones) {
z.n = [0, 0];
if (G.mode === 'showdown' && z.id !== 'B') continue;
for (const e of G.ents) if (e.alive && Math.hypot(e.x - z.x, e.z - z.z) < z.r && e.y < 3) z.n[e.team]++;
z.contest = z.n[0] > 0 && z.n[1] > 0;
if (z.contest) continue;
const t = z.n[0] > 0 ? 0 : z.n[1] > 0 ? 1 : -1; if (t < 0) continue;
const dir = t === 0 ? 1 : -1; const rate = 0.3 * Math.min(3, z.n[t]) ** 0.6;
const before = z.prog; z.prog = Math.max(-1, Math.min(1, z.prog + dir * rate * dt));
if (z.owner !== t && Math.sign(before) !== Math.sign(z.prog) && z.owner !== -1 && Math.abs(before) > 0) { z.owner = -1; ev(G, { k: 'neutral', zone: z.id }); }
if (Math.abs(z.prog) >= 1 && z.owner !== t) {
z.owner = t; ev(G, { k: 'cap', zone: z.id, team: t });
for (const e of G.ents) if (e.alive && e.team === t && Math.hypot(e.x - z.x, e.z - z.z) < z.r) { e.caps++; e.score += 150; }
if (G.mode === 'showdown') { endRound(G, t); break; }
}
}
// scoring
if (G.mode === 'turf') {
for (const z of G.zones) if (z.owner >= 0) G.score[z.owner] += dt;
for (const e of G.ents) if (e.alive) for (const z of G.zones) if (z.owner === e.team && Math.hypot(e.x - z.x, e.z - z.z) < z.r) e.score += 10 * dt;
if (G.score[0] >= G.target || G.score[1] >= G.target || G.left <= 0) finish(G, G.score[0] > G.score[1] ? 0 : G.score[1] > G.score[0] ? 1 : -1);
} else if (!G.roundOver) {
const alive = [0, 1].map((t) => G.ents.some((e) => e.team === t && e.alive));
if (!alive[0] || !alive[1]) endRound(G, alive[0] ? 0 : 1);
else if (G.left <= 0) { const z = G.zones[1]; endRound(G, z.owner >= 0 ? z.owner : z.prog > 0.05 ? 0 : z.prog < -0.05 ? 1 : (G.ents.filter((e) => e.team === 0 && e.alive).length >= G.ents.filter((e) => e.team === 1 && e.alive).length ? 0 : 1)); }
}
record(G);
return G;
}
function endRound(G, t) {
if (G.roundOver > 0 || G.over) return;
G.rounds[t]++; ev(G, { k: 'round', team: t, rounds: G.rounds.slice() });
for (const e of G.ents) if (e.team === t) e.score += 100;
if (G.rounds[t] >= G.toWin) finish(G, t); else G.roundOver = 3;
}
function nextRound(G) {
G.round++; G.left = G.time; G.charges = []; G.strikes = []; G.sentries = []; G.recon = [0, 0];
for (const z of G.zones) { z.owner = -1; z.prog = 0; }
for (const e of G.ents) { spawn(G, e, true); e.owned = []; }
ev(G, { k: 'roundStart', round: G.round });
}
function finish(G, winner) {
if (G.over) return; G.over = true; G.winner = winner;
for (const e of G.ents) if (e.team === winner) e.score += 500;
ev(G, { k: 'end', winner });
}
// ---------------------------------------------------------------- history (30 Hz) for kill-cams and replays
function record(G) {
if (G.tick % 2) return;
const s = new Float32Array(G.ents.length * HS);
G.ents.forEach((e, i) => {
const o = i * HS; s[o] = e.x; s[o + 1] = e.z; s[o + 2] = e.yaw; s[o + 3] = e.alive ? 1 : 0; s[o + 4] = e.moving; s[o + 5] = e.firing; s[o + 6] = e.hp / e.maxHp; s[o + 7] = e.dash > 0 ? 1 : 0; s[o + 8] = e.kick;
s[o + 9] = e.y; s[o + 10] = e.pitch; s[o + 11] = e.crouch; s[o + 12] = e.slot + (e.reload > 0 ? 2 : 0) + e.ads * 0.5;
});
G.hist.push({ tick: G.tick, t: G.t, s, sentries: G.sentries.map((q) => [q.x, q.z, q.yaw, q.team, q.firing]), zones: G.zones.map((z) => [z.owner, z.prog]), charges: G.charges.map((c) => [c.x0, c.z0, c.x, c.z, c.t, c.fly, c.y0 || 1.5, c.y || 0]) });
}
/** History frame index at or after tick. */
export function histAt(G, tick) { let lo = 0, hi = G.hist.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (G.hist[m].tick < tick) lo = m + 1; else hi = m; } return lo; }
/** Events from the history between two ticks (for replays). */
export function histEvents(G, t0, t1) { return G.histEv.filter((e) => e.tick > t0 && e.tick <= t1); }
/** The player's best moment: the window around their biggest multi-kill / streak (else the last kill they made). */
export function bestMoment(G, who = G.you) {
if (!who) return null;
const kills = G.histEv.filter((e) => e.k === 'kill' && e.by === who.id);
if (!kills.length) return null;
let best = kills[kills.length - 1], score = -1;
for (const k of kills) { const s = k.multi * 10 + (k.streak >= 5 ? 6 : k.streak >= 3 ? 3 : 0) + (k.w === 'skyfall' ? 4 : 0) + (k.hs ? 2 : 0); if (s >= score) { score = s; best = k; } }
const first = kills.filter((k) => k.tick <= best.tick && best.tick - k.tick < 60 * 4.5)[0] || best;
return { t0: Math.max(0, first.tick - 60 * 2.2), t1: best.tick + 60 * 1.4, multi: best.multi, streak: best.streak, w: best.w, hs: !!best.hs };
}
/** Result summary for the player (score card, contract check, challenge links). */
export function result(G) {
const y = G.you; if (!y) return null;
const mates = G.ents.slice().sort((a, b) => b.score - a.score);
return {
mode: G.mode, map: G.map.id, win: G.winner === y.team, draw: G.winner < 0, score: Math.round(y.score), kills: y.kills, deaths: y.deaths, assists: y.assists, heads: y.heads,
caps: y.caps, bestStreak: y.bestStreak, weapon: y.weapon, perk: y.perk, team: [Math.round(G.score[0]), Math.round(G.score[1])], rounds: G.rounds.slice(),
mvp: mates[0].id === y.id, rank: mates.indexOf(y) + 1, board: mates.map((e) => ({ name: e.name, team: e.team, you: e.you, k: e.kills, d: e.deaths, s: Math.round(e.score) })),
};
}
export { TEAMS };