diff --git a/spec/fight-sim-spec.mjs b/spec/fight-sim-spec.mjs new file mode 100644 index 0000000000..53e7f7ac54 --- /dev/null +++ b/spec/fight-sim-spec.mjs @@ -0,0 +1,146 @@ +import { readFileSync } from "fs"; +import * as sim from "../system/public/aesthetic.computer/lib/fight/sim.mjs"; +import { syncTest, report } from "../system/public/aesthetic.computer/lib/fight/rollback.mjs"; + +const SRC = "system/public/aesthetic.computer/lib/fight/sim.mjs"; + +// a scripted match. seeded separately from the sim's own rng so the input +// stream is fixed while the sim's randomness is still exercised. +// +// directions are held for a stretch and buttons are tapped rarely — press a +// button every frame and both fighters spend the whole match in attack +// recovery, never walk, never touch, and the synctest proves nothing. +const BUTTONS = [sim.LIGHT, sim.MEDIUM, sim.HEAVY]; + +function script(seed) { + let a = seed | 0; + const next = () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return (t ^ (t >>> 14)) >>> 0; + }; + // weighted toward closing the distance, so the fighters actually meet. + const dirs = (fwd, back) => [fwd, fwd, fwd, back, 0, 0, sim.DOWN, sim.UP]; + const tables = [dirs(sim.RIGHT, sim.LEFT), dirs(sim.LEFT, sim.RIGHT)]; + const held = [0, 0]; + return (tick) => { + const out = [0, 0]; + for (const p of [0, 1]) { + if (tick % (5 + p * 2) === 0) held[p] = tables[p][next() % tables[p].length]; + out[p] = held[p]; + if (next() % 19 === 0) out[p] |= BUTTONS[next() % 3]; + } + return out; + }; +} + +describe("fight sim determinism", () => { + it("never calls a nondeterministic builtin", () => { + const src = readFileSync(SRC, "utf8").replace(/^\s*\/\/.*$/gm, ""); + const banned = + /Math\.(random|sin|cos|tan|asin|acos|atan|atan2|exp|log|log2|log10|pow|hypot|cbrt|sinh|cosh|tanh)\b|Date\.now|performance\.now/; + const hit = src.match(banned); + expect(hit ? hit[0] : null).toBeNull(); + }); + + it("produces an identical checksum stream from identical inputs", () => { + const inputs = script(7); + const a = sim.create(1); + const b = sim.create(1); + for (let f = 0; f < 900; f++) { + const [i0, i1] = inputs(f); + sim.step(a, i0, i1); + sim.step(b, i0, i1); + expect(sim.checksum(a)).toBe(sim.checksum(b)); + } + }); + + it("round-trips through snapshot and restore", () => { + const inputs = script(11); + const s = sim.create(1); + for (let f = 0; f < 120; f++) sim.step(s, ...inputs(f)); + + const snap = sim.snapshot(s); + const before = sim.checksum(s); + for (let f = 0; f < 30; f++) sim.step(s, ...inputs(f + 120)); + expect(sim.checksum(s)).not.toBe(before); // the sim actually moved + + sim.restore(s, snap); + expect(sim.checksum(s)).toBe(before); + }); + + it("advances its rng, so the rollback tests are load-bearing", () => { + const inputs = script(3); + const s = sim.create(1); + const seed = s[sim.G.RNG]; + let moved = false; + for (let f = 0; f < 900 && !moved; f++) { + sim.step(s, ...inputs(f)); + if (s[sim.G.RNG] !== seed) moved = true; + } + expect(moved).toBe(true); + }); + + // the real gate: ggpo's synctest across the whole prediction window. + for (const distance of [1, 2, 4, 8]) { + it(`survives a ${distance}-frame rollback every frame`, () => { + const r = syncTest(sim, script(distance * 31), { frames: 900, distance }); + if (!r.ok) fail(report(r)); + expect(r.ok).toBe(true); + }); + } +}); + +describe("fight sim rules", () => { + const idle = () => [0, 0]; + + it("lands a hit and takes health", () => { + const s = sim.create(1); + // walk p0 into range, then poke. + for (let f = 0; f < 40; f++) sim.step(s, sim.RIGHT, 0); + const hp = s[sim.PN + sim.P.HP]; + for (let f = 0; f < 20; f++) sim.step(s, f < 2 ? sim.LIGHT : 0, 0); + expect(s[sim.PN + sim.P.HP]).toBeLessThan(hp); + }); + + it("blocks when the defender holds away", () => { + const s = sim.create(1); + for (let f = 0; f < 40; f++) sim.step(s, sim.RIGHT, 0); + const hp = s[sim.PN + sim.P.HP]; + // p1 is on the right, so backing away is RIGHT. blockstun is short, so + // watch for it rather than checking one frame after the fact. + let blocked = false; + for (let f = 0; f < 20; f++) { + sim.step(s, f < 2 ? sim.LIGHT : 0, sim.RIGHT); + if (s[sim.PN + sim.P.ST] === sim.ST.BLOCKSTUN) blocked = true; + } + expect(blocked).toBe(true); + expect(s[sim.PN + sim.P.HP]).toBe(hp); + }); + + it("keeps fighters inside the stage and out of each other", () => { + const s = sim.create(1); + for (let f = 0; f < 400; f++) sim.step(s, sim.LEFT, sim.LEFT); + expect(s[sim.P.X]).toBeGreaterThanOrEqual(sim.BODY_W >> 1); + expect(Math.abs(s[sim.PN + sim.P.X] - s[sim.P.X])).toBeGreaterThanOrEqual( + (sim.BODY_W >> 1) - 1, + ); + }); + + it("ends the round when health runs out", () => { + const s = sim.create(1); + s[sim.PN + sim.P.HP] = 10; + for (let f = 0; f < 40; f++) sim.step(s, sim.RIGHT, 0); + for (let f = 0; f < 40; f++) sim.step(s, f % 20 < 2 ? sim.HEAVY : 0, 0); + expect(s[sim.G.OVER]).toBe(1); + }); + + it("idles to a timeout decision", () => { + const s = sim.create(1); + s[sim.G.TIMER] = 3; + s[sim.P.HP] = 500; + for (let f = 0; f < 5; f++) sim.step(s, ...idle()); + expect(s[sim.G.OVER]).toBe(2); // p1 kept more health + }); +}); diff --git a/system/public/aesthetic.computer/disks/fight.mjs b/system/public/aesthetic.computer/disks/fight.mjs new file mode 100644 index 0000000000..642fbf6f48 --- /dev/null +++ b/system/public/aesthetic.computer/disks/fight.mjs @@ -0,0 +1,170 @@ +// fight, 26.07.09 +// hotseat versus on one qwerty. phase 0 of the rollback plan: the sim is +// deterministic and snapshot-clean, and nothing here touches the network yet. +// +// p1 w a s d · f g h (light / medium / heavy) +// p2 ← ↑ ↓ → · j k l +// +// `fight:boxes` overlays hit and hurt boxes. `fight:synctest` runs the +// rollback harness in the browser and prints the verdict. + +import * as game from "../lib/fight/sim.mjs"; +import { syncTest, report } from "../lib/fight/rollback.mjs"; + +const { P, PN, G, ST, SUB, BODY_W, BODY_H, CROUCH_H, MOVES } = game; + +const KEYS = { + w: [0, game.UP], + a: [0, game.LEFT], + s: [0, game.DOWN], + d: [0, game.RIGHT], + f: [0, game.LIGHT], + g: [0, game.MEDIUM], + h: [0, game.HEAVY], + arrowup: [1, game.UP], + arrowleft: [1, game.LEFT], + arrowdown: [1, game.DOWN], + arrowright: [1, game.RIGHT], + j: [1, game.LIGHT], + k: [1, game.MEDIUM], + l: [1, game.HEAVY], +}; + +const SUIT = [ + [90, 170, 255], + [255, 110, 90], +]; + +let s; // the whole simulation, one Int32Array +let held = [0, 0]; +let half = 0; // ac sims at 120hz; the game ticks at 60 +let boxes = false; +let verdict; // synctest result, when asked for + +function boot({ colon }) { + s = game.create(1); + boxes = colon.includes("boxes"); + if (colon.includes("synctest")) { + // buttons stay rare on purpose — tap one every frame and both fighters + // spend the match in recovery, never meet, and the test proves nothing. + const inputs = (f) => [ + (f % 7 < 4 ? game.RIGHT : f % 7 < 6 ? game.LEFT : game.UP) | + (f % 23 === 0 ? game.MEDIUM : 0), + (f % 5 < 3 ? game.LEFT : game.DOWN) | (f % 31 === 0 ? game.LIGHT : 0), + ]; + verdict = report(syncTest(game, inputs, { frames: 600, distance: 8 })); + } +} + +function sim() { + if ((half ^= 1)) return; // every other 120hz step + if (s[G.OVER]) return; + game.step(s, held[0], held[1]); +} + +function act({ event: e }) { + for (const key in KEYS) { + const [p, bit] = KEYS[key]; + if (e.is(`keyboard:down:${key}`)) held[p] |= bit; + if (e.is(`keyboard:up:${key}`)) held[p] &= ~bit; + } + if (e.is("keyboard:down:r") && s[G.OVER]) s = game.create(s[G.RNG]); +} + +// render only. everything below reads state and writes pixels — it never +// writes back, which is what keeps the sim rollback-safe. +function paint({ wipe, ink, screen, write }) { + const { width: w, height: h } = screen; + const ox = (w - 256) >> 1; + const floor = h - 18; + const px = (v) => ox + ((v / SUB) | 0); + const py = (v) => floor - ((v / SUB) | 0); + + wipe(18, 16, 26); + ink(40, 38, 58).box(ox, floor, 256, 1); + for (let x = 0; x <= 256; x += 32) ink(30, 28, 44).box(ox + x, floor + 1, 1, 4); + + for (let p = 0; p < 2; p++) { + const b = p * PN; + const st = s[b + P.ST]; + const tall = st === ST.CROUCH ? CROUCH_H : BODY_H; + const x = px(s[b + P.X] - (BODY_W >> 1)); + const y = py(s[b + P.Y] + tall); + const bw = BODY_W / SUB; + const bh = tall / SUB; + + let c = SUIT[p]; + if (st === ST.HITSTUN) c = [255, 240, 200]; + if (st === ST.BLOCKSTUN) c = [190, 200, 220]; + if (st === ST.KO) c = [70, 60, 70]; + + ink(...c).box(x, y, bw, bh); + // a notch on the leading edge, so facing reads at a glance + const nose = s[b + P.FACE] > 0 ? x + bw - 2 : x; + ink(255).box(nose, y + 3, 2, 4); + + if (st === ST.ATTACK) { + const m = MOVES[s[b + P.MV]]; + const f = s[b + P.STF]; + const live = f >= m.startup && f < m.startup + m.active; + const arm = live ? m.reach / SUB : (m.reach / SUB) >> 2; + const ax = s[b + P.FACE] > 0 ? x + bw : x - arm; + if (live) ink(255, 255, 255).box(ax, py(28 * SUB), arm, 3); + else ink(120, 120, 140).box(ax, py(28 * SUB), arm, 3); + } + + if (s[b + P.SPL] > 0) { + const sx = px(s[b + P.X] + s[b + P.SPX]); + const sy = py(s[b + P.SPY]); + const r = s[b + P.SPL]; + ink(255, 230, 120).box(sx - (r >> 1), sy - (r >> 1), r, r); + } + + if (boxes) { + ink(80, 255, 120, 90).box(x, y, bw, bh); // hurt + if (st === ST.ATTACK) { + const m = MOVES[s[b + P.MV]]; + const f = s[b + P.STF]; + if (f >= m.startup && f < m.startup + m.active) { + const front = s[b + P.X] + s[b + P.FACE] * (BODY_W >> 1); + const hx = s[b + P.FACE] > 0 ? front : front - m.reach; + ink(255, 60, 90, 120).box( + px(hx), + py(34 * SUB), + m.reach / SUB, + (34 - 18), + ); + } + } + } + } + + // health, timer, and the numbers that matter while debugging. + for (let p = 0; p < 2; p++) { + const hp = s[p * PN + P.HP]; + const bw = ((hp * 110) / game.START_HP) | 0; + const x = p === 0 ? 6 : w - 6 - 110; + ink(50, 45, 60).box(x, 6, 110, 6); + ink(...SUIT[p]).box(p === 0 ? x : x + 110 - bw, 6, bw, 6); + } + ink(220).write(`${(s[G.TIMER] / 60) | 0}`, { x: (w >> 1) - 6, y: 5 }); + + ink(70, 66, 90).write(`t${s[G.TICK]} ${game.checksum(s).toString(16)}`, { + x: 6, + y: 16, + }); + + if (s[G.OVER]) { + const msg = s[G.OVER] === 3 ? "draw" : `p${s[G.OVER]} wins`; + ink(0, 0, 0, 190).box((w >> 1) - 44, (h >> 1) - 14, 88, 28); + ink(255, 220, 60).write(msg, { x: (w >> 1) - msg.length * 3, y: (h >> 1) - 8 }); + ink(150).write("r to rematch", { x: (w >> 1) - 33, y: (h >> 1) + 2 }); + } + + ink(60, 58, 78).write("wasd+fgh", { x: 6, y: h - 9 }); + ink(60, 58, 78).write("arrows+jkl", { x: w - 62, y: h - 9 }); + + if (verdict) ink(120, 255, 160).write(verdict, { x: 6, y: 26 }); +} + +export { boot, paint, act, sim }; diff --git a/system/public/aesthetic.computer/lib/fight/rollback.mjs b/system/public/aesthetic.computer/lib/fight/rollback.mjs new file mode 100644 index 0000000000..1640c4638a --- /dev/null +++ b/system/public/aesthetic.computer/lib/fight/rollback.mjs @@ -0,0 +1,79 @@ +// fight/rollback.mjs — the safety net that has to be green before any of the +// networking gets written. +// +// syncTest is ggpo's `ggpo_start_synctest`, and factorio's "heavy mode" wearing +// a different hat: every frame, rewind `distance` frames and replay them from +// the snapshot. identical inputs must land on an identical checksum. if they +// don't, some state lives outside the snapshot — a module-level variable, an +// unrestored rng cursor, a cached derived value — and rollback over a wire +// would desync in a way that is miserable to debug at 60hz across two machines. +// +// this catches it locally, on one machine, in a single process. + +// a fixed ring of snapshots, sized to the prediction window. +export function ring(sim, len) { + const slots = Array.from({ length: len }, () => sim.create()); + const ticks = new Int32Array(len).fill(-1); + return { + save(tick, s) { + const i = tick % len; + slots[i].set(s); + ticks[i] = tick; + }, + load(tick, s) { + const i = tick % len; + if (ticks[i] !== tick) return false; + s.set(slots[i]); + return true; + }, + has: (tick) => ticks[tick % len] === tick, + }; +} + +// walk two states and report the fields that differ. +export function diff(sim, a, b) { + const out = []; + for (let i = 0; i < sim.SIZE; i++) + if (a[i] !== b[i]) out.push({ field: sim.label(i), live: a[i], replay: b[i] }); + return out; +} + +// `inputs(tick)` returns [i0, i1]. returns { ok } or a desync report. +export function syncTest(sim, inputs, { frames = 600, distance = 2 } = {}) { + const live = sim.create(); + const snaps = []; + const ins = []; + const sums = []; + + for (let f = 0; f < frames; f++) { + snaps[f] = sim.snapshot(live); + ins[f] = inputs(f); + sim.step(live, ins[f][0], ins[f][1]); + sums[f] = sim.checksum(live); + + const from = f - distance + 1; + if (from < 0) continue; + + const replay = sim.snapshot(snaps[from]); + for (let g = from; g <= f; g++) sim.step(replay, ins[g][0], ins[g][1]); + + if (sim.checksum(replay) !== sums[f]) { + return { + ok: false, + frame: f, + from, + distance, + fields: diff(sim, live, replay), + }; + } + } + return { ok: true, frames, distance }; +} + +export function report(r) { + if (r.ok) return `synctest ok — ${r.frames} frames, distance ${r.distance}`; + const fields = r.fields + .map((d) => `${d.field}: live ${d.live} ≠ replay ${d.replay}`) + .join("\n "); + return `desync at frame ${r.frame} (replayed from ${r.from})\n ${fields}`; +} diff --git a/system/public/aesthetic.computer/lib/fight/sim.mjs b/system/public/aesthetic.computer/lib/fight/sim.mjs new file mode 100644 index 0000000000..b845c5f2df --- /dev/null +++ b/system/public/aesthetic.computer/lib/fight/sim.mjs @@ -0,0 +1,328 @@ +// fight/sim.mjs — a deterministic integer fighting simulation. +// +// every value that matters lives in one Int32Array, so a snapshot is a slice +// and a checksum is a walk. no floats reach state, and nothing here calls +// Math.random or the transcendentals — the spec calls sin/cos/pow/atan2 +// "implementation-approximated" and lets engines pick their own algorithm, +// so a single Math.sin would desync chrome against safari. the toolkit is +// + - * / and Math.imul/abs/min/max, all of which are exactly specified. +// +// positions are subpixels. y is height above the floor, so y === 0 is grounded. + +export const SUB = 256; // subpixels per pixel +export const STAGE_W = 256 * SUB; + +export const BODY_W = 22 * SUB; +export const BODY_H = 46 * SUB; +export const CROUCH_H = 28 * SUB; + +const WALK = 384; // forward, ~1.5px per frame +const BACK = 288; // retreating is slower, as it should be +const GRAV = 60; +const JUMP_V = 1500; +const AIR = 320; // horizontal momentum locked in at takeoff + +const HIT_LO = 18 * SUB; // every move swings at the same height for now +const HIT_HI = 34 * SUB; + +const HIT_STOP = 8; +const BLOCK_STOP = 5; +const PUSH = 3 * SUB; + +// input bits. one u16 per player per frame — this is the whole wire format. +export const UP = 1, + DOWN = 2, + LEFT = 4, + RIGHT = 8, + LIGHT = 16, + MEDIUM = 32, + HEAVY = 64; + +export const ST = { + IDLE: 0, + WALK: 1, + CROUCH: 2, + JUMP: 3, + ATTACK: 4, + HITSTUN: 5, + BLOCKSTUN: 6, + KO: 7, +}; + +// frame data. constant, never snapshotted. +export const MOVES = [ + { startup: 3, active: 2, recovery: 6, dmg: 30, stun: 12, block: 6, reach: 30 * SUB }, + { startup: 6, active: 3, recovery: 12, dmg: 60, stun: 16, block: 8, reach: 36 * SUB }, + { startup: 10, active: 4, recovery: 20, dmg: 100, stun: 22, block: 10, reach: 44 * SUB }, +]; + +// per-fighter field offsets. +export const P = { + X: 0, + Y: 1, + VX: 2, + VY: 3, + FACE: 4, + HP: 5, + ST: 6, + STF: 7, // frames elapsed in ATTACK, frames remaining in stun + MV: 8, + STOP: 9, // hitstop + HIT: 10, // this attack already connected + PIN: 11, // previous frame's input, for edge detection + SPX: 12, // hit spark — visual, but derived from the rng so it must roll back + SPY: 13, + SPL: 14, +}; +export const PN = 15; + +export const G = { TICK: 30, RNG: 31, TIMER: 32, OVER: 33 }; +export const SIZE = 34; + +export const START_HP = 1000; +export const ROUND_TICKS = 99 * 60; + +export function create(seed = 1) { + const s = new Int32Array(SIZE); + for (let p = 0; p < 2; p++) { + const b = p * PN; + s[b + P.X] = p === 0 ? 80 * SUB : 176 * SUB; + s[b + P.FACE] = p === 0 ? 1 : -1; + s[b + P.HP] = START_HP; + } + s[G.RNG] = seed | 0; + s[G.TIMER] = ROUND_TICKS; + return s; +} + +export const snapshot = (s) => s.slice(); +export const restore = (s, snap) => s.set(snap); + +// mulberry32. integer-only, so it survives the trip across engines, and its +// state sits in the array so a rollback rewinds the randomness too. +function rnd(s) { + const a = (s[G.RNG] + 0x6d2b79f5) | 0; + s[G.RNG] = a; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return (t ^ (t >>> 14)) >>> 0; +} + +export function checksum(s) { + let h = 0x811c9dc5; + for (let i = 0; i < SIZE; i++) { + const v = s[i]; + for (let k = 0; k < 32; k += 8) { + h ^= (v >>> k) & 255; + h = Math.imul(h, 0x01000193); + } + } + return h >>> 0; +} + +const clampX = (x) => Math.min(STAGE_W - (BODY_W >> 1), Math.max(BODY_W >> 1, x)); +const grounded = (s, b) => s[b + P.Y] === 0; + +export function step(s, i0, i1) { + s[G.TICK]++; + if (s[G.OVER]) return; + + // hitstop freezes both fighters. inputs still latch so a button held + // through the freeze doesn't read as a fresh press on the far side. + if (s[P.STOP] > 0 || s[PN + P.STOP] > 0) { + if (s[P.STOP] > 0) s[P.STOP]--; + if (s[PN + P.STOP] > 0) s[PN + P.STOP]--; + s[P.PIN] = i0; + s[PN + P.PIN] = i1; + return; + } + + if (s[G.TIMER] > 0) s[G.TIMER]--; + for (let p = 0; p < 2; p++) if (s[p * PN + P.SPL] > 0) s[p * PN + P.SPL]--; + + face(s); + control(s, 0, i0); + control(s, 1, i1); + physics(s, 0); + physics(s, 1); + separate(s); + + // probe both before applying either, so a trade doesn't depend on index order. + const h0 = probe(s, 0); + const h1 = probe(s, 1); + if (h0) connect(s, 0); + if (h1) connect(s, 1); + + for (let p = 0; p < 2; p++) { + const b = p * PN; + if (s[b + P.HP] <= 0) { + s[b + P.HP] = 0; + s[b + P.ST] = ST.KO; + s[G.OVER] = 2 - p; // 1 → p0 wins, 2 → p1 wins + } + } + if (!s[G.OVER] && s[G.TIMER] === 0) { + const a = s[P.HP], + b = s[PN + P.HP]; + s[G.OVER] = a === b ? 3 : a > b ? 1 : 2; + } +} + +function face(s) { + for (let p = 0; p < 2; p++) { + const b = p * PN, + st = s[b + P.ST]; + if (st !== ST.IDLE && st !== ST.WALK && st !== ST.CROUCH) continue; + const d = s[(1 - p) * PN + P.X] - s[b + P.X]; + if (d !== 0) s[b + P.FACE] = d > 0 ? 1 : -1; + } +} + +function control(s, p, inp) { + const b = p * PN; + const press = inp & ~s[b + P.PIN]; + s[b + P.PIN] = inp; + + const st = s[b + P.ST]; + + if (st === ST.HITSTUN || st === ST.BLOCKSTUN) { + if (--s[b + P.STF] <= 0) s[b + P.ST] = grounded(s, b) ? ST.IDLE : ST.JUMP; + return; + } + + if (st === ST.ATTACK) { + const m = MOVES[s[b + P.MV]]; + if (++s[b + P.STF] >= m.startup + m.active + m.recovery) { + s[b + P.ST] = ST.IDLE; + s[b + P.HIT] = 0; + } + return; + } + + if (st === ST.JUMP) return; // no air attacks yet + + const btn = press & (LIGHT | MEDIUM | HEAVY); + if (btn) { + s[b + P.ST] = ST.ATTACK; + s[b + P.MV] = btn & LIGHT ? 0 : btn & MEDIUM ? 1 : 2; + s[b + P.STF] = 0; + s[b + P.HIT] = 0; + return; + } + + if (inp & DOWN) { + s[b + P.ST] = ST.CROUCH; + return; + } + + if (press & UP) { + s[b + P.ST] = ST.JUMP; + s[b + P.VY] = JUMP_V; + s[b + P.VX] = inp & RIGHT ? AIR : inp & LEFT ? -AIR : 0; + return; + } + + const dir = inp & RIGHT ? 1 : inp & LEFT ? -1 : 0; + if (dir) { + s[b + P.X] = clampX(s[b + P.X] + dir * (dir === s[b + P.FACE] ? WALK : BACK)); + s[b + P.ST] = ST.WALK; + } else { + s[b + P.ST] = ST.IDLE; + } +} + +function physics(s, p) { + const b = p * PN; + if (s[b + P.Y] === 0 && s[b + P.VY] === 0) return; + s[b + P.VY] -= GRAV; + s[b + P.Y] += s[b + P.VY]; + s[b + P.X] = clampX(s[b + P.X] + s[b + P.VX]); + if (s[b + P.Y] <= 0) { + s[b + P.Y] = 0; + s[b + P.VY] = 0; + s[b + P.VX] = 0; + if (s[b + P.ST] === ST.JUMP) s[b + P.ST] = ST.IDLE; + } +} + +function separate(s) { + const d = s[PN + P.X] - s[P.X]; + const ad = Math.abs(d); + if (ad >= BODY_W) return; + const push = ((BODY_W - ad) >> 1) + 1; + const sgn = d >= 0 ? 1 : -1; + s[P.X] = clampX(s[P.X] - sgn * push); + s[PN + P.X] = clampX(s[PN + P.X] + sgn * push); +} + +// does p's active hitbox overlap the other fighter's hurtbox this frame? +function probe(s, p) { + const b = p * PN; + if (s[b + P.ST] !== ST.ATTACK || s[b + P.HIT]) return 0; + + const m = MOVES[s[b + P.MV]]; + const f = s[b + P.STF]; + if (f < m.startup || f >= m.startup + m.active) return 0; + + const fc = s[b + P.FACE]; + const front = s[b + P.X] + fc * (BODY_W >> 1); + const x0 = fc > 0 ? front : front - m.reach; + const x1 = fc > 0 ? front + m.reach : front; + + const ob = (1 - p) * PN; + const oy = s[ob + P.Y]; + const oh = s[ob + P.ST] === ST.CROUCH ? CROUCH_H : BODY_H; + const ox0 = s[ob + P.X] - (BODY_W >> 1); + const ox1 = s[ob + P.X] + (BODY_W >> 1); + + if (x1 <= ox0 || x0 >= ox1) return 0; + if (HIT_HI <= oy || HIT_LO >= oy + oh) return 0; + return 1; +} + +function connect(s, p) { + const b = p * PN, + ob = (1 - p) * PN; + const m = MOVES[s[b + P.MV]]; + const fc = s[b + P.FACE]; + s[b + P.HIT] = 1; + + const st = s[ob + P.ST]; + const canBlock = + grounded(s, ob) && (st === ST.IDLE || st === ST.WALK || st === ST.CROUCH); + // backing away from the attacker is a block. the attacker faces `fc`, so the + // defender's escape direction carries the same sign. + const away = fc > 0 ? RIGHT : LEFT; + const blocking = canBlock && (s[ob + P.PIN] & away) !== 0; + + if (blocking) { + s[ob + P.ST] = ST.BLOCKSTUN; + s[ob + P.STF] = m.block; + s[b + P.STOP] = BLOCK_STOP; + s[ob + P.STOP] = BLOCK_STOP; + } else { + s[ob + P.HP] -= m.dmg; + s[ob + P.ST] = ST.HITSTUN; + s[ob + P.STF] = m.stun; + s[b + P.STOP] = HIT_STOP; + s[ob + P.STOP] = HIT_STOP; + s[ob + P.SPX] = (rnd(s) % (9 * SUB)) - 4 * SUB; + s[ob + P.SPY] = 20 * SUB + (rnd(s) % (12 * SUB)); + s[ob + P.SPL] = 10; + } + + s[b + P.X] = clampX(s[b + P.X] - fc * PUSH); + s[ob + P.X] = clampX(s[ob + P.X] + fc * PUSH); +} + +// index → name, so a desync report can say which field drifted. +export function label(i) { + if (i >= SIZE) return `?${i}`; + if (i >= G.TICK) { + const g = Object.keys(G).find((k) => G[k] === i); + return g ?? `?${i}`; + } + const p = (i / PN) | 0; + const f = Object.keys(P).find((k) => P[k] === i - p * PN); + return `p${p}.${f}`; +}