File size: 24,493 Bytes
217da1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
// Turfbreak core: seeded city maps of Port Varo (a made-up harbour city), weapons, perks, killstreaks, 3D collision
// (feet height, step-up, mantling onto cover), 3D line of sight and hitscan rays, and the navigation grid. Pure functions only (no DOM, no three.js), so the same file runs in the
// browser, in Node tests and in the trailer recorder. Every map is point-symmetric, so both crews get the same layout.
// (c) 2026 CyberMax. All rights reserved.

export const VERSION = 2; // 2 = first-person (3D bodies, heights, headshots)
export const LAUNCH_DAY = Date.UTC(2026, 9, 16) / 864e5; // 16 Oct 2026 = Contract #1

// ---------------------------------------------------------------- random
export function rng(seed) {
  let a = seed >>> 0;
  const f = () => { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
  f.range = (lo, hi) => lo + (hi - lo) * f();
  f.int = (lo, hi) => Math.floor(lo + (hi - lo + 1) * f());
  f.pick = (arr) => arr[Math.floor(f() * arr.length)];
  f.chance = (p) => f() < p;
  return f;
}
export function hashStr(s) { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return h >>> 0; }
export const dayNumber = (now = Date.now()) => Math.floor(now / 864e5) - LAUNCH_DAY + 1;

// ---------------------------------------------------------------- crews, weapons, perks, streaks
export const TEAMS = [
  { id: 0, name: 'ASHEN', color: '#FF8A1F', glow: '#FFB25C', dark: '#3A1C08' },
  { id: 1, name: 'COLDWIRE', color: '#2EE6FF', glow: '#8AF4FF', dark: '#06303A' },
];
// Fictional weapons. dmg per bullet, cd = seconds between shots, spread in radians, range in metres.
// First-person fields: hs = headshot multiplier, zoom = aim-down-sights magnification, recoil = upward kick per shot
// (radians, applied to the view by the game), bloom = spread added per shot (decays), sight = 'dot' | 'scope' | 'iron'.
export const WEAPONS = {
  vk9: { id: 'vk9', name: 'VK-9 CARBINE', short: 'VK-9', kind: 'Carbine', dmg: 19, cd: 0.105, pellets: 1, spread: 0.035, range: 34, mag: 30, reload: 1.6, speed: 1, auto: true, kick: 0.25, adsT: 0.16, desc: 'Full-auto all-rounder', hs: 1.6, zoom: 1.35, recoil: 0.011, bloom: 0.006, sight: 'dot' },
  wasp: { id: 'wasp', name: 'WASP SMG', short: 'WASP', kind: 'SMG', dmg: 13, cd: 0.068, pellets: 1, spread: 0.06, range: 22, mag: 36, reload: 1.35, speed: 1.08, auto: true, kick: 0.18, adsT: 0.13, desc: 'Fast hands, fast feet', hs: 1.5, zoom: 1.25, recoil: 0.008, bloom: 0.005, sight: 'iron' },
  breach: { id: 'breach', name: 'BREACH-12', short: 'BREACH', kind: 'Shotgun', dmg: 14, cd: 0.78, pellets: 8, spread: 0.11, range: 13, mag: 6, reload: 2.1, speed: 1, auto: true, kick: 0.9, adsT: 0.18, desc: 'Owns every doorway', hs: 1.25, zoom: 1.15, recoil: 0.05, bloom: 0, sight: 'iron' },
  lancer: { id: 'lancer', name: 'LANCER DMR', short: 'LANCER', kind: 'Marksman', dmg: 48, cd: 0.4, pellets: 1, spread: 0.01, range: 50, mag: 10, reload: 2.0, speed: 0.95, auto: true, kick: 0.55, adsT: 0.22, desc: 'Three taps from across the street', hs: 2.1, zoom: 2.6, recoil: 0.035, bloom: 0.012, sight: 'scope' },
};
/** Everyone also carries the sidearm (slot 2). Not a loadout pick, so it is kept out of WEAPON_IDS. */
export const SIDEARM = { id: 'p9', name: 'TALLY P9 SIDEARM', short: 'P9', kind: 'Sidearm', dmg: 24, cd: 0.17, pellets: 1, spread: 0.03, range: 26, mag: 12, reload: 1.15, speed: 1.06, auto: false, kick: 0.3, adsT: 0.12, desc: 'Backup that never jams', hs: 1.8, zoom: 1.2, recoil: 0.02, bloom: 0.008, sight: 'iron' };
export const ARMS = { ...WEAPONS, p9: SIDEARM };
export const WEAPON_IDS = Object.keys(WEAPONS);
export const PERKS = {
  plated: { id: 'plated', name: 'PLATED', desc: '+30 armour' },
  ghost: { id: 'ghost', name: 'GHOST', desc: 'Hidden from drones, silent dash' },
  quick: { id: 'quick', name: 'QUICKHANDS', desc: 'Faster reload and dash' },
};
export const PERK_IDS = Object.keys(PERKS);
export const STREAKS = [
  { at: 3, id: 'recon', name: 'RECON DRONE', desc: 'Every enemy on your map for 12 s' },
  { at: 5, id: 'sentry', name: 'SENTRY', desc: 'An auto-turret holds your ground' },
  { at: 7, id: 'skyfall', name: 'SKYFALL STRIKE', desc: 'A line of strikes down your aim' },
];

// ---------------------------------------------------------------- maps
export const MAPS = {
  row: { id: 'row', name: 'NEON ROW', area: 'Port Varo · Market District', fog: '#0B1020' },
  docks: { id: 'docks', name: 'VARO DOCKS', area: 'Port Varo · Container Terminal', fog: '#08131A' },
  yard: { id: 'yard', name: 'SIGNAL YARD', area: 'Port Varo · Rail Depot', fog: '#120D14' },
};
export const MAP_IDS = Object.keys(MAPS);
export const BOUND = { x: 46, z: 50 };
// Bodies in metres (feet at y): eye height, hit cylinder top and head sphere, standing and crouched.
export const BODY = { r: 0.4, eye: 1.62, eyeC: 1.04, top: 1.42, topC: 0.86, head: 1.6, headC: 0.98, headR: 0.18, step: 0.45, mantle: 0.9 };
const COLS = [-30, 0, 30], ROWS = [-30, 0, 30], HALF = 9;

/** One obstacle: axis-aligned box centred at (x,z), size w (x) by d (z), height h. */
const box = (x, z, w, d, h, kind, extra = {}) => ({ x, z, w, d, h, kind, ...extra });
const mirror = (o) => ({ ...o, x: -o.x, z: -o.z, mir: true, rot: o.rot != null ? o.rot + Math.PI : o.rot });

function overlaps(a, b, pad = 0) { return Math.abs(a.x - b.x) * 2 < a.w + b.w + pad * 2 && Math.abs(a.z - b.z) * 2 < a.d + b.d + pad * 2; }

/**
 * Build a map. Same (id, seed) -> same map everywhere. Returns obstacles (collision + render), zones, spawns, lamps,
 * signs and decor (render only), plus a nav grid for the bots.
 */
export function genMap(id = 'row', seed = 1) {
  const R = rng(seed ^ hashStr(id));
  const half = []; // obstacles for the south half (z > 0) and the east middle (z == 0 row, x > 0), mirrored after
  const decorH = []; const signsH = []; const lampsH = [];
  const zones = [{ id: 'A', x: -30, z: 0, r: 5.2 }, { id: 'B', x: 0, z: 0, r: 5.2 }, { id: 'C', x: 30, z: 0, r: 5.2 }];
  const keepClear = [...zones.map((z) => ({ x: z.x, z: z.z, w: z.r * 2 + 1.2, d: z.r * 2 + 1.2 })), { x: 0, z: 45, w: 60, d: 6 }];
  const free = (o, pad = 0.6) => !keepClear.some((k) => overlaps(o, k, 0)) && !half.some((h) => overlaps(o, h, pad));
  const add = (o, pad) => { if (free(o, pad)) { half.push(o); return true; } return false; };

  // perimeter: tall buildings around the playable area (backdrop + walls)
  // the camera always looks north, so the south edge is a low harbour wall and the other three sides are a tall skyline
  const per = [], perN = [];
  for (let x = -54; x <= 54; x += 12) { per.push(box(x, BOUND.z + 6, 12, 12, 2.4, 'wall')); perN.push(box(x, -BOUND.z - 6, 12, 12, R.range(14, 30), 'bldg', { facade: R.int(0, 3) })); }
  for (let z = -42; z <= 42; z += 12) { const h = R.range(14, 30); perN.push(box(BOUND.x + 6, z, 12, 12, h, 'bldg', { facade: R.int(0, 3) }), box(-BOUND.x - 6, -z, 12, 12, h, 'bldg', { facade: R.int(0, 3) })); }

  const blockFill = {
    row(cx, cz) { // city block: two or three buildings with an alley through
      const vertical = R.chance(0.5); const gap = R.range(2.6, 3.4); const off = R.range(-3, 3);
      if (vertical) {
        const wl = HALF + off - gap / 2, wr = HALF - off - gap / 2;
        add(box(cx - HALF + wl / 2, cz, wl, HALF * 2, R.range(5, 15), 'bldg', { facade: R.int(0, 3), sign: true }), 0);
        add(box(cx + HALF - wr / 2, cz, wr, HALF * 2, R.range(5, 15), 'bldg', { facade: R.int(0, 3), sign: true }), 0);
      } else {
        const dl = HALF + off - gap / 2, dr = HALF - off - gap / 2;
        add(box(cx, cz - HALF + dl / 2, HALF * 2, dl, R.range(5, 15), 'bldg', { facade: R.int(0, 3), sign: true }), 0);
        add(box(cx, cz + HALF - dr / 2, HALF * 2, dr, R.range(5, 15), 'bldg', { facade: R.int(0, 3), sign: true }), 0);
      }
    },
    docks(cx, cz) { // container stacks with lanes
      for (let i = -1; i <= 1; i++) for (let j = -1; j <= 1; j += 2) {
        if (R.chance(0.18)) continue;
        const h = R.chance(0.45) ? 5.2 : 2.6;
        add(box(cx + i * 6.2, cz + j * 4.6, 2.5, 6.1, h, 'container', { tint: R.int(0, 5) }), 0.2);
      }
    },
    yard(cx, cz) { // parked rail cars along x with a platform
      add(box(cx, cz - 5, 17, 3.1, 3.6, 'train', { tint: R.int(0, 3) }), 0.3);
      if (R.chance(0.7)) add(box(cx + R.range(-2, 2), cz + 1.5, 13, 3.1, 3.6, 'train', { tint: R.int(0, 3) }), 0.3);
      add(box(cx, cz + 6.5, 16, 2.4, 1.0, 'platform'), 0.3);
    },
  };
  const fill = blockFill[id] || blockFill.row;
  // south row blocks (z = +30) and the east middle block's flanks
  for (const cx of COLS) fill(cx, 30);
  // middle row: the lots (A at -30, C at 30) and the plaza (B). Only the east side is generated; mirror makes the west.
  const lotCover = (cx, cz) => {
    const kinds = id === 'docks' ? ['container', 'crate', 'barrier'] : id === 'yard' ? ['crate', 'barrier', 'pillar'] : ['car', 'barrier', 'dumpster'];
    for (let k = 0; k < 7; k++) {
      const kind = R.pick(kinds); const a = R.range(0, Math.PI * 2); const r = R.range(6.2, 8.6);
      const x = cx + Math.cos(a) * r, z = cz + Math.sin(a) * r;
      const along = R.chance(0.5);
      const dims = { car: [2.0, 4.4, 1.5], barrier: [3.2, 0.7, 1.2], dumpster: [2.0, 1.4, 1.5], container: [2.5, 6.1, 2.6], crate: [1.4, 1.4, 1.4], pillar: [1.2, 1.2, 4] }[kind];
      add(box(x, z, along ? dims[0] : dims[1], along ? dims[1] : dims[0], dims[2], kind, { tint: R.int(0, 5) }), 0.9);
    }
  };
  lotCover(30, 0);
  // plaza: low cover around zone B (generated for the half x > 0, mirrored)
  for (const [x, z, w, d, h, kind] of [[7.6, -3.4, 1.0, 3.8, 1.2, 'planter'], [3.6, 7.4, 3.4, 1.0, 1.2, 'planter'], [8.4, 6.8, 2.2, 2.2, 2.4, 'kiosk']]) add(box(x, z, w, d, h, kind), 0.2);
  // street furniture between blocks (south half): cars at the curb, barriers, crates, dumpsters
  for (let k = 0; k < 26; k++) {
    const vertical = R.chance(0.5);
    let x, z;
    if (vertical) { x = R.pick([-15, 15, 41.5]) + R.range(-3.2, 3.2); z = R.range(2, 46); } else { z = R.pick([15, 44]) + R.range(-3, 3); x = R.range(-44, 44); }
    const kind = id === 'docks' ? R.pick(['container', 'crate', 'crate', 'barrier']) : id === 'yard' ? R.pick(['crate', 'barrier', 'barrier', 'pillar']) : R.pick(['car', 'car', 'barrier', 'dumpster', 'crate']);
    const dims = { car: [2.0, 4.4, 1.5], barrier: [3.2, 0.7, 1.2], dumpster: [2.0, 1.4, 1.5], container: [2.5, 6.1, 2.6], crate: [1.4, 1.4, 1.4], pillar: [1.2, 1.2, 4] }[kind];
    const along = vertical ? kind !== 'barrier' : kind === 'barrier';
    add(box(x, z, along ? dims[0] : dims[1], along ? dims[1] : dims[0], dims[2], kind, { tint: R.int(0, 5) }), 1.6);
  }
  // climbing steps: a crate against some containers and kiosks, so their tops are reachable (jump + mantle)
  for (const o of half.slice()) {
    if (!['container', 'kiosk'].includes(o.kind) || o.h > 3 || !R.chance(0.65)) continue;
    const side = R.int(0, 3); const along = side % 2 === 0; // 0/2: +-z ends, 1/3: +-x sides
    const off = along ? (R() - 0.5) * Math.max(0, o.w - 1.4) : (R() - 0.5) * Math.max(0, o.d - 1.4);
    const sx = side === 1 ? 1 : side === 3 ? -1 : 0, sz = side === 0 ? 1 : side === 2 ? -1 : 0;
    const c = box(o.x + sx * (o.w / 2 + 0.71) + (along ? off : 0), o.z + sz * (o.d / 2 + 0.71) + (along ? 0 : off), 1.4, 1.4, 1.4, 'crate', { tint: R.int(0, 5), step: true });
    if (keepClear.some((k) => overlaps(c, k, 0)) || half.some((h) => h !== o && overlaps(c, h, 0.9)) || overlaps(c, o, -0.01)) continue;
    if (Math.abs(c.x) > BOUND.x - 1 || Math.abs(c.z) > BOUND.z - 1) continue;
    half.push(c);
  }
  // street lamps and neon signs (render only)
  for (const x of [-15, 15]) for (const z of [8, 24, 40]) lampsH.push({ x: x + (x < 0 ? -4.2 : 4.2), z });
  for (const z of [15]) for (const x of [-36, -6, 24]) lampsH.push({ x, z: z + 4.8 });
  for (const o of half) if (o.kind === 'bldg' && o.sign && R.chance(0.85)) signsH.push({ on: o, side: R.int(0, 3), word: R.int(0, 9), hue: R.int(0, 4), y: Math.min(o.h - 1.2, R.range(2.6, 4.2)) });
  for (let k = 0; k < 18; k++) decorH.push({ kind: R.pick(['puddle', 'puddle', 'puddle', 'vent', 'cone', 'trash']), x: R.range(-44, 44), z: R.range(1, 48), s: R.range(0.6, 1.8), a: R.range(0, 6.28) });

  // mirror everything through the centre
  const lamps = [...lampsH, ...lampsH.map((l) => ({ x: -l.x, z: -l.z }))];
  // lamp posts are solid too (thin poles: they stop bodies and bullets; never used as cover by bots)
  const obstacles = [...half, ...half.map(mirror), ...per, ...perN, ...lamps.map((l) => box(l.x, l.z, 0.24, 0.24, 5.4, 'lamp'))];
  const signs = [...signsH, ...signsH.map((s) => ({ ...s, on: mirror(s.on), side: (s.side + 2) % 4 }))];
  const decor = [...decorH, ...decorH.map((d) => ({ ...d, x: -d.x, z: -d.z }))].filter((d) => !obstacles.some((o) => inBox(o, d.x, d.z, 0.8)));
  const spawns = [[-22, -8, 8, 22, 0].map((x) => ({ x, z: 45.5, yaw: -Math.PI / 2 })), [22, 8, -8, -22, 0].map((x) => ({ x, z: -45.5, yaw: Math.PI / 2 }))];
  const map = { id, name: (MAPS[id] || MAPS.row).name, seed, obstacles, zones, spawns, lamps, signs, decor };
  map.nav = navGrid(map);
  return map;
}

// ---------------------------------------------------------------- geometry
export function inBox(o, x, z, pad = 0) { return Math.abs(x - o.x) <= o.w / 2 + pad && Math.abs(z - o.z) <= o.d / 2 + pad; }

/** Push a circle (x, z, r) out of every obstacle and keep it inside the bounds. Returns [x, z]. */
export function collide(map, x, z, r = 0.45) {
  for (let pass = 0; pass < 2; pass++) {
    for (const o of map.obstacles) {
      const hx = o.w / 2 + r, hz = o.d / 2 + r; const dx = x - o.x, dz = z - o.z;
      if (Math.abs(dx) >= hx || Math.abs(dz) >= hz) continue;
      const px = hx - Math.abs(dx), pz = hz - Math.abs(dz);
      if (px < pz) x += Math.sign(dx || 1) * px; else z += Math.sign(dz || 1) * pz;
    }
  }
  x = Math.max(-BOUND.x + r, Math.min(BOUND.x - r, x)); z = Math.max(-BOUND.z + r, Math.min(BOUND.z - r, z));
  return [x, z];
}

/** Ray from (ax,az) towards (bx,bz): distance fraction 0..1 to the first obstacle hit, or 1 when clear. */
export function rayHit(map, ax, az, bx, bz, minH = 0.9) {
  const dx = bx - ax, dz = bz - az; let best = 1;
  for (const o of map.obstacles) {
    if (o.h < minH) continue;
    const x0 = o.x - o.w / 2, x1 = o.x + o.w / 2, z0 = o.z - o.d / 2, z1 = o.z + o.d / 2;
    let t0 = 0, t1 = best;
    if (Math.abs(dx) < 1e-9) { if (ax < x0 || ax > x1) continue; } else { let a = (x0 - ax) / dx, b = (x1 - ax) / dx; if (a > b) [a, b] = [b, a]; t0 = Math.max(t0, a); t1 = Math.min(t1, b); if (t0 > t1) continue; }
    if (Math.abs(dz) < 1e-9) { if (az < z0 || az > z1) continue; } else { let a = (z0 - az) / dz, b = (z1 - az) / dz; if (a > b) [a, b] = [b, a]; t0 = Math.max(t0, a); t1 = Math.min(t1, b); if (t0 > t1) continue; }
    if (t0 < best) best = t0;
  }
  return best;
}
export const los = (map, ax, az, bx, bz) => rayHit(map, ax, az, bx, bz) >= 0.999;

/** Distance along a ray (unit dir dx,dz from x,z) to a circle at (cx,cz) radius r, or -1. */
export function rayCircle(x, z, dx, dz, cx, cz, r) {
  const ox = cx - x, oz = cz - z; const t = ox * dx + oz * dz; if (t < 0) return -1;
  const d2 = ox * ox + oz * oz - t * t; if (d2 > r * r) return -1;
  return t - Math.sqrt(r * r - d2);
}

// ---------------------------------------------------------------- 3D: heights, climbing, rays
/** Highest standable top under a body circle whose feet are at `feet` (tops up to a step above count). */
export function groundAt(map, x, z, feet = 0, r = BODY.r * 0.8) {
  let g = 0;
  for (const o of map.obstacles) {
    if (o.h > feet + BODY.step || o.h <= g) continue;
    if (Math.abs(x - o.x) < o.w / 2 + r && Math.abs(z - o.z) < o.d / 2 + r) g = o.h;
  }
  return g;
}
/**
 * collide() in 3D: boxes whose top is below feet + step are walked over (stood on), not pushed against.
 * The body is a circle against each box (closest point), so it slides along walls and rounds corners and thin
 * poles instead of snagging on them; a centre that ends up inside a box leaves by the shortest way out.
 */
export function collide3(map, x, z, feet = 0, r = 0.45) {
  for (let pass = 0; pass < 3; pass++) {
    let moved = false;
    for (const o of map.obstacles) {
      if (o.h <= feet + BODY.step) continue;
      const hw = o.w / 2, hd = o.d / 2; const dx = x - o.x, dz = z - o.z;
      if (Math.abs(dx) >= hw + r || Math.abs(dz) >= hd + r) continue;
      if (Math.abs(dx) <= hw && Math.abs(dz) <= hd) { // centre inside: shortest axis out
        const px = hw + r - Math.abs(dx), pz = hd + r - Math.abs(dz);
        if (px < pz) x += Math.sign(dx || 1) * px; else z += Math.sign(dz || 1) * pz; moved = true; continue;
      }
      const cx = Math.max(-hw, Math.min(hw, dx)), cz = Math.max(-hd, Math.min(hd, dz)); const ex = dx - cx, ez = dz - cz;
      const d2 = ex * ex + ez * ez; if (d2 >= r * r) continue;
      const d = Math.sqrt(d2) || 1e-6; const push = r - d; x += (ex / d) * push; z += (ez / d) * push; moved = true;
    }
    if (!moved) break;
  }
  x = Math.max(-BOUND.x + r, Math.min(BOUND.x - r, x)); z = Math.max(-BOUND.z + r, Math.min(BOUND.z - r, z));
  return [x, z];
}
/** A box the body (at x,z, feet) is pressing against that it can mantle onto (top within reach), or null. */
export function mantleTarget(map, x, z, feet, r = 0.5) {
  let best = null;
  for (const o of map.obstacles) {
    if (o.h <= feet + BODY.step || o.h > feet + BODY.mantle || o.kind === 'bldg' || o.kind === 'wall') continue;
    if (Math.abs(x - o.x) < o.w / 2 + r && Math.abs(z - o.z) < o.d / 2 + r && (!best || o.h > best.h)) best = o;
  }
  return best;
}
/**
 * 3D ray from A towards B against every box (y from 0 to h) and the ground: { f: fraction 0..1 of the first hit
 * (1 = clear), nx, ny, nz: surface normal there }.
 */
export function rayHit3(map, ax, ay, az, bx, by, bz) {
  const dx = bx - ax, dy = by - ay, dz = bz - az; let best = 1, n = [0, 0, 0];
  if (dy < -1e-9) { const t = -ay / dy; if (t >= 0 && t < best) { best = t; n = [0, 1, 0]; } }
  for (const o of map.obstacles) {
    const lo = [o.x - o.w / 2, 0, o.z - o.d / 2], hi = [o.x + o.w / 2, o.h, o.z + o.d / 2]; const p = [ax, ay, az], d = [dx, dy, dz];
    let t0 = 0, t1 = best, axis = -1, sgn = 0, miss = false;
    for (let k = 0; k < 3; k++) {
      if (Math.abs(d[k]) < 1e-9) { if (p[k] < lo[k] || p[k] > hi[k]) { miss = true; break; } continue; }
      let a = (lo[k] - p[k]) / d[k], b = (hi[k] - p[k]) / d[k], s = -1;
      if (a > b) { [a, b] = [b, a]; s = 1; }
      if (a > t0) { t0 = a; axis = k; sgn = s; }
      if (b < t1) t1 = b;
      if (t0 > t1) { miss = true; break; }
    }
    if (miss || axis < 0 || t0 >= best) continue;
    best = t0; n = [0, 0, 0]; n[axis] = sgn;
  }
  return { f: best, nx: n[0], ny: n[1], nz: n[2] };
}
export const los3 = (map, ax, ay, az, bx, by, bz) => rayHit3(map, ax, ay, az, bx, by, bz).f >= 0.999;
/** Unit view direction for yaw (around y, 0 = +x) and pitch (up positive). */
export const dir3 = (yaw, pitch) => { const c = Math.cos(pitch); return [Math.cos(yaw) * c, Math.sin(pitch), Math.sin(yaw) * c]; };
/**
 * Ray (origin o, unit dir d) against a standing body (feet at fx,fy,fz; crouch 0..1): distance to the first hit, or -1,
 * and whether it is the head. Body = vertical cylinder, head = sphere on top.
 */
export function rayBody(o, d, fx, fy, fz, crouch = 0) {
  const top = fy + BODY.top + (BODY.topC - BODY.top) * crouch; const hy = fy + BODY.head + (BODY.headC - BODY.head) * crouch;
  let tBody = -1;
  { // cylinder (x,z circle), clipped to [fy, top]
    const ox = o[0] - fx, oz = o[2] - fz; const a = d[0] * d[0] + d[2] * d[2]; const b = 2 * (ox * d[0] + oz * d[2]); const c = ox * ox + oz * oz - BODY.r * BODY.r;
    let lo = -Infinity, hi = Infinity;
    if (a > 1e-12) { const D = b * b - 4 * a * c; if (D >= 0) { const s = Math.sqrt(D); lo = (-b - s) / (2 * a); hi = (-b + s) / (2 * a); } else { lo = 1; hi = 0; } } else if (c > 0) { lo = 1; hi = 0; }
    if (Math.abs(d[1]) > 1e-12) { let y0 = (fy - o[1]) / d[1], y1 = (top - o[1]) / d[1]; if (y0 > y1) [y0, y1] = [y1, y0]; lo = Math.max(lo, y0); hi = Math.min(hi, y1); } else if (o[1] < fy || o[1] > top) { lo = 1; hi = 0; }
    if (lo <= hi && hi >= 0) tBody = Math.max(0, lo);
  }
  let tHead = -1;
  { const ox = o[0] - fx, oy = o[1] - hy, oz = o[2] - fz; const b = ox * d[0] + oy * d[1] + oz * d[2]; const c = ox * ox + oy * oy + oz * oz - BODY.headR * BODY.headR; const D = b * b - c; if (D >= 0) { const t = -b - Math.sqrt(D); if (t >= 0) tHead = t; } }
  if (tHead >= 0 && (tBody < 0 || tHead <= tBody + 0.05)) return { t: tHead, head: true };
  return tBody >= 0 ? { t: tBody, head: false } : null;
}

// ---------------------------------------------------------------- navigation (grid BFS, 1 m cells)
export function navGrid(map) {
  const W = BOUND.x * 2, H = BOUND.z * 2; const block = new Uint8Array(W * H);
  for (let j = 0; j < H; j++) for (let i = 0; i < W; i++) {
    const x = i - BOUND.x + 0.5, z = j - BOUND.z + 0.5;
    if (map.obstacles.some((o) => inBox(o, x, z, 0.55))) block[j * W + i] = 1;
  }
  return { W, H, block, cache: new Map() };
}
const cellOf = (nav, x, z) => { const i = Math.max(0, Math.min(nav.W - 1, Math.floor(x + BOUND.x))); const j = Math.max(0, Math.min(nav.H - 1, Math.floor(z + BOUND.z))); return j * nav.W + i; };
/** Distance field (in steps) to the target point; cached per target cell. */
export function field(nav, tx, tz) {
  const t = cellOf(nav, tx, tz); let f = nav.cache.get(t); if (f) return f;
  const { W, H, block } = nav; f = new Int32Array(W * H).fill(-1); const q = new Int32Array(W * H); let qh = 0, qt = 0;
  f[t] = 0; q[qt++] = t;
  while (qh < qt) {
    const c = q[qh++]; const ci = c % W, cj = (c - ci) / W; const d = f[c] + 1;
    if (ci > 0 && f[c - 1] < 0 && !block[c - 1]) { f[c - 1] = d; q[qt++] = c - 1; }
    if (ci < W - 1 && f[c + 1] < 0 && !block[c + 1]) { f[c + 1] = d; q[qt++] = c + 1; }
    if (cj > 0 && f[c - W] < 0 && !block[c - W]) { f[c - W] = d; q[qt++] = c - W; }
    if (cj < H - 1 && f[c + W] < 0 && !block[c + W]) { f[c + W] = d; q[qt++] = c + W; }
  }
  if (nav.cache.size > 64) nav.cache.clear();
  nav.cache.set(t, f); return f;
}
/** Unit direction that walks down the distance field from (x, z) towards its target; [0,0] when there. */
export function steer(nav, f, x, z) {
  const c = cellOf(nav, x, z); const { W } = nav; const here = f[c];
  if (here === 0) return [0, 0];
  let best = here < 0 ? 1e9 : here, bx = 0, bz = 0;
  const ci = c % W, cj = (c - ci) / W;
  for (let dj = -2; dj <= 2; dj++) for (let di = -2; di <= 2; di++) {
    if (!di && !dj) continue; const i = ci + di, j = cj + dj; if (i < 0 || j < 0 || i >= W || j >= nav.H) continue;
    const v = f[j * W + i]; if (v < 0) continue;
    const s = v + Math.hypot(di, dj) * 0.01;
    if (s < best) { best = s; bx = di; bz = dj; }
  }
  const l = Math.hypot(bx, bz) || 1; return [bx / l, bz / l];
}
export function reachable(map, ax, az, bx, bz) { const f = field(map.nav, bx, bz); return f[cellOf(map.nav, ax, az)] >= 0; }

// ---------------------------------------------------------------- the daily contract
const CONTRACTS = [
  { id: 'kills12', text: 'Win with 12+ eliminations', test: (r) => r.win && r.kills >= 12 },
  { id: 'caps4', text: 'Capture 4 zones', test: (r) => r.caps >= 4 },
  { id: 'streak5', text: 'Call in a Sentry (5 in a row)', test: (r) => r.bestStreak >= 5 },
  { id: 'score3k', text: 'Score 3,000 points', test: (r) => r.score >= 3000 },
  { id: 'kd2', text: 'Finish with 2.0 K/D or better', test: (r) => r.kills >= 4 && r.kills >= r.deaths * 2 },
  { id: 'win', text: 'Win the match', test: (r) => r.win },
  { id: 'lancer8', text: 'Win with the Lancer and 8+ eliminations', test: (r) => r.win && r.weapon === 'lancer' && r.kills >= 8 },
  { id: 'breach8', text: '8 eliminations with the Breach-12', test: (r) => r.weapon === 'breach' && r.kills >= 8 },
];
export function contract(day) {
  const R = rng(hashStr(`contract-${day}`));
  const map = MAP_IDS[((day % MAP_IDS.length) + MAP_IDS.length) % MAP_IDS.length];
  const c = CONTRACTS[Math.floor(R() * CONTRACTS.length)];
  return { day, map, seed: hashStr(`turf-${day}`), id: c.id, text: c.text, test: c.test };
}
export const fmt = (n) => Math.round(n).toLocaleString('en-US');