From c02301ee37d7813f3d67fcc27e1dcaa22ca30751 Mon Sep 17 00:00:00 2001 From: Stephen DeLorme Date: Wed, 2 Sep 2026 17:44:13 -0400 Subject: [PATCH 1/3] Pay for a participant, blame the arena on nobody, and carry the kills Milestone 8, first half: who is paid for what, and a feed that says so on every machine. Attribution had two holes the code named and deferred: an unattributable kill fell back to seat 0, and shooting another participant paid nothing. Both were exactly right for one seat and arbitrary for two. Now: - Damage the arena inflicts -- a mine, a scrape -- carries a faction of its own, `FACTION_ENVIRONMENT`, instead of being blamed on "the other side" (`notMe`, gone). Who is *credited* is a rule in one place: the seat that last landed a hit on the victim, so a hostile chased onto a mine still scores for the chaser; in a match of one seat, that seat, which is the shipped scoreline bit for bit (the seeded baseline is untouched); with more seats and no last hitter, nobody. The star names the victim's own faction and is never a hit for anyone, as before. Arena damage pays points but not a hit: accuracy is bolts landed over bolts fired. - A participant's hull pays like any other: the hit to whoever landed it, the kill to whoever is owed it, at `PARTICIPANT_BOUNTY_MULT` (two) times the airframe's bounty, through the shooter's streak. Never the victim. - Every kill is a `KillEvent` -- killer, victim, hull, award, sequence -- and the snapshot carries the last four (`FEED_RING`). The host announces as it happens; a mirror announces each event once as it arrives, by sequence, which survives lost and reordered snapshots. Same text from the same event on every machine, with only YOU moving: the shooter reads "P2 DOWN +300", the victim "DOWNED BY P1", everyone else "P1 > P2 DOWN". Snapshot version 2. Checks: two Hornets on the autopilot with hulls the size of stations and an untouchable squadron fight to a death, every death is the other's kill, the award is a rung of the streak, and both seats' feeds read as above from the same events. Two mined waves: one untouched before anybody fires (pays nobody, the feed blames the arena; in a match of one, pays that seat), one worked over by a single shooter (pays the shooter, not the idle seat). The pinned "shooting a participant scores nothing yet" check is replaced. The sealed-loss check that relied on a dead seat 0 going on earning now pins the opposite. Over both wire tests the mirror's feed has exactly the host's line count, including at 30% loss. Simulation checks 567 -> 584, eleven new mutations. Not here yet: the match result -- a winner, a placing, a debrief on the mirror -- which is the second half. Co-authored-by: Claude Fable 5.1 Signed-off-by: Stephen DeLorme --- scripts/mutate.mjs | 70 +++++++++- scripts/simcheck.ts | 332 ++++++++++++++++++++++++++++++++++++-------- src/game/bolts.ts | 12 ++ src/game/game.ts | 192 ++++++++++++++++++------- src/game/roster.ts | 11 +- src/game/ship.ts | 25 +--- src/net/snapshot.ts | 48 ++++++- 7 files changed, 555 insertions(+), 135 deletions(-) diff --git a/scripts/mutate.mjs b/scripts/mutate.mjs index 541885f..bdccb9c 100644 --- a/scripts/mutate.mjs +++ b/scripts/mutate.mjs @@ -287,6 +287,74 @@ const MUTATIONS = [ to: ' game.acknowledge(seat, -1)', }, + /* ---- Match rules: attribution and the feed ------------------------------- */ + { + name: 'a kill with no author pays seat 0 whatever the roster', + file: 'src/game/game.ts', + from: ' return seatOf(seats, from) ?? lastHitter.get(victim) ?? soleSeat()', + to: ' return seatOf(seats, from) ?? lastHitter.get(victim) ?? seats[0] ?? null', + }, + { + name: 'a kill with no author pays nobody, even the last hitter', + file: 'src/game/game.ts', + from: ' return seatOf(seats, from) ?? lastHitter.get(victim) ?? soleSeat()', + to: ' return seatOf(seats, from) ?? soleSeat()', + }, + { + name: 'the last hitter is never remembered', + file: 'src/game/game.ts', + from: ' if (direct) {\n lastHitter.set(self, direct)\n creditHit(direct, amount)\n return\n }', + to: ' if (direct) {\n creditHit(direct, amount)\n return\n }', + }, + { + name: "the arena's damage is nobody's, even in a match of one", + file: 'src/game/game.ts', + from: ' if (from === FACTION_ENVIRONMENT) return lastHitter.get(victim) ?? soleSeat()', + to: ' if (from === FACTION_ENVIRONMENT) return null', + }, + { + name: 'a hit on a participant pays nothing', + file: 'src/game/game.ts', + from: ' lastHitter.set(self, direct)\n creditHit(direct, amount)', + to: ' lastHitter.set(self, direct)', + }, + { + name: 'a participant kill pays the victim', + file: 'src/game/game.ts', + from: ' const scorer = owed && owed !== seat ? owed : null', + to: ' const scorer = owed', + }, + { + name: "a participant's hull is worth the same as the squadron's", + file: 'src/game/game.ts', + from: ' const award = scorer ? creditKill(scorer, Math.round(self.spec.bounty * PARTICIPANT_BOUNTY_MULT)) : 0', + to: ' const award = scorer ? creditKill(scorer, self.spec.bounty) : 0', + }, + { + name: 'a mirror announces every kill in every snapshot', + file: 'src/game/game.ts', + from: ' if (e.seq > feedSeen) {\n feedSeen = e.seq\n announceKill(e)\n }', + to: ' announceKill(e)', + }, + { + name: 'the feed ring never lets go', + file: 'src/game/game.ts', + from: ' if (feed.length > FEED_RING) feed.shift()', + to: '', + }, + { + name: 'the feed is never captured', + file: 'src/game/game.ts', + from: ' feed: feed.map((e) => ({ ...e })),', + to: ' feed: [],', + }, + { + name: 'a scrape is blamed on the other side again', + file: 'src/game/ship.ts', + from: ' this.takeDamage(Math.min(55, 4 + impact * 0.1), FACTION_ENVIRONMENT)', + to: ' this.takeDamage(Math.min(55, 4 + impact * 0.1), (this.faction === 0 ? -1 : 0) as Faction)', + }, + /* ---- Snapshot pacing ----------------------------------------------------- */ { name: 'the client applies a snapshot the tick it arrives', @@ -683,7 +751,7 @@ function runSuite() { * If you added or removed checks on purpose, bump this in the same commit. If you did not, * something stopped running. */ -const EXPECTED_ASSERTIONS = 567 +const EXPECTED_ASSERTIONS = 584 const PASS_SUMMARY = 'All checks passed.' const SUMMARY = /check\(s\) failed\.$|All checks passed\.$/ diff --git a/scripts/simcheck.ts b/scripts/simcheck.ts index fc5d5a7..095e345 100644 --- a/scripts/simcheck.ts +++ b/scripts/simcheck.ts @@ -22,7 +22,10 @@ import { admitIntent, bound, rampThrottle, THROTTLE_DOWN_RATE, THROTTLE_UP_RATE import { decodeSnapshot, encodeSnapshot, + FEED_RING, + NOBODY, SNAPSHOT_VERSION, + THE_ARENA, type ShipState, type WorldSnapshot, } from '../src/net/snapshot' @@ -30,7 +33,7 @@ import { decodeIntent, encodeIntent, INTENT_FRAME_BYTES, INTENT_VERSION } from ' import { createLoopback } from '../src/net/channel' import { modeFromLocation } from '../src/net/browser' import { createClient, createHost, decodeWelcome, encodeWelcome, FRAME, SNAPSHOT_DEPTH, SNAPSHOT_QUEUE } from '../src/net/session' -import { createGame, DEATH_SEQUENCE, type Game, type GameDeps, type RunSnapshot } from '../src/game/game' +import { createGame, DEATH_SEQUENCE, PARTICIPANT_BOUNTY_MULT, type Game, type GameDeps, type RunSnapshot } from '../src/game/game' import { barBrightness, DAMAGE_BAR_FADE, DAMAGE_BAR_HOLD, type Hud } from '../src/game/hud' import { createSeats, isParticipant, seatOf } from '../src/game/roster' import { createDevHook, installDevHook, type DevHook } from '../src/core/dev-hook' @@ -2896,6 +2899,11 @@ function testTheWorldSurvivesTheWire(): void { { live: false, respawnIn: Math.fround(12.5) }, ], mines: [true, false, true], + feed: [ + { seq: 7, killer: 0, victim: NOBODY, hull: 'wasp', award: 150 }, + { seq: 8, killer: THE_ARENA, victim: 1, hull: 'hornet', award: 0 }, + { seq: 9, killer: 1, victim: 0, hull: 'drone', award: 240 }, + ], } const bytes = encodeSnapshot(world) @@ -3255,8 +3263,10 @@ function testAMatchCrossesTheWire(): void { const TICKS = Math.ceil(25 / STEP) function fly(connected: boolean) { - const hostGame = newMatch() - const clientGame = newMatch() + const hostHud = feedRecordingHud() + const clientHud = feedRecordingHud() + const hostGame = newMatch({ hud: hostHud }) + const clientGame = newMatch({ hud: clientHud }) const host = createHost({ game: hostGame, setup: { ships: ['hornet', 'wasp'], seed: SEED, respawn: true } }) host.start() const wire = createLoopback() @@ -3302,7 +3312,11 @@ function testAMatchCrossesTheWire(): void { if (s1 && s1.hull < SHIPS.wasp.maxHull) fought = true } const print = matchPrint(hostGame) - const result = { seat, welcomed, compared, mismatched, first, fought, print, host, client, hostTick: client.hostTick } + const kills = hostGame.capture().feed.length > 0 ? hostGame.capture().feed[hostGame.capture().feed.length - 1].seq : 0 + const result = { + seat, welcomed, compared, mismatched, first, fought, print, host, client, hostTick: client.hostTick, + kills, hostFeed: hostHud.lines, clientFeed: clientHud.lines, + } hostGame.dispose() clientGame.dispose() return result @@ -3322,6 +3336,13 @@ function testAMatchCrossesTheWire(): void { live.host.stats.wrongSeat === 0 && live.host.stats.stale === 0 && live.host.stats.malformed === 0 && live.client.stats.stale === 0 && live.client.stats.malformed === 0, JSON.stringify(live.host.stats)) + check('the host announced every kill once, and so did the mirror', live.kills > 0 && live.hostFeed.length === live.kills && live.clientFeed.length === live.kills, + `${live.kills} kills; host ${live.hostFeed.length} lines, client ${live.clientFeed.length}`) + check('from its own seat: the same kill reads YOU on one side and P on the other', + live.hostFeed.some((l) => l.includes('YOU') || l.startsWith('WASP DOWN') || l.startsWith('DRONE DOWN') || l.includes('P2')) && + live.clientFeed.every((l) => !l.includes('P2')) && live.hostFeed.every((l) => !l.includes('P1')), + JSON.stringify({ host: live.hostFeed.slice(0, 4), client: live.clientFeed.slice(0, 4) })) + const deaf = fly(false) check('a seat nobody is flying flies differently', deaf.print !== live.print, 'the client\'s stick changed nothing on the host') } @@ -3339,8 +3360,10 @@ function testABadWireIsSurvived(): void { section('A bad wire is survived') const TICKS = Math.ceil(20 / STEP) - const hostGame = newMatch() - const clientGame = newMatch() + const hostHud = feedRecordingHud() + const clientHud = feedRecordingHud() + const hostGame = newMatch({ hud: hostHud }) + const clientGame = newMatch({ hud: clientHud }) const host = createHost({ game: hostGame, setup: { ships: ['hornet', 'wasp'], seed: 0xbad, respawn: true } }) host.start() const wire = createLoopback({ loss: 0.3, latency: 1, jitter: 3, duplicate: 0.1, seed: 99 }) @@ -3405,6 +3428,8 @@ function testABadWireIsSurvived(): void { checkedAgainstHistory > TICKS * 0.5 && historyMismatch === 0, `${historyMismatch} mismatched`) check('the client kept up', client.hostTick > TICKS - 40, `client at ${client.hostTick}, host at ${TICKS}`) check('lost snapshots were coasted through, not waited for', c.coasted > 0 && c.coasted < TICKS * 0.4, `${c.coasted} coasted`) + check('and every kill still reached the mirror\'s feed exactly once', hostHud.lines.length > 0 && clientHud.lines.length === hostHud.lines.length, + `host ${hostHud.lines.length} lines, client ${clientHud.lines.length}`) hostGame.dispose() clientGame.dispose() } @@ -4347,64 +4372,251 @@ function testTwoScorersKeepSeparateStreaks(): void { (zero.hits !== one.hits || zero.score !== one.score || zero.shotsFired !== one.shotsFired), `seat 0 ${zero?.hits}/${zero?.score}/${zero?.shotsFired}, seat 1 ${one?.hits}/${one?.score}/${one?.shotsFired}`, ) + // A Hornet volley is two bolts and `shotsFired` counts volleys, so a seat can + // land more hits than it fired shots — and with the other seat now paying for + // hits too (milestone 8), it does. check( 'accuracy is each seat’s own hits over its own shots', zero !== undefined && one !== undefined && - zero.hits <= zero.shotsFired && one.hits <= one.shotsFired, + zero.hits <= zero.shotsFired * SHIPS.hornet.barrels && one.hits <= one.shotsFired * SHIPS.hornet.barrels, `seat 0 ${zero?.hits}/${zero?.shotsFired}, seat 1 ${one?.hits}/${one?.shotsFired}`, ) } +/** A HUD stub that keeps every feed line and callout, in order. */ +function feedRecordingHud(): Hud & { lines: string[]; callouts: string[] } { + const hud = stubHud() as Hud & { lines: string[]; callouts: string[] } + hud.lines = [] + hud.callouts = [] + hud.feed = (text) => { + hud.lines.push(text) + } + hud.callout = (text) => { + hud.callouts.push(text) + } + return hud +} + /** - * What a participant shooting another participant is worth: nothing, yet. + * What a participant shooting another participant is worth: milestone 8's answer. * - * This pins a *gap*, deliberately, so that closing it is a decision rather than an - * accident. Hits and bounties are credited against the AI squadron only — the - * enemy ships are where `onDamaged` and `onDeath` do the crediting — so a bolt - * that lands on another seat does damage and pays no points. + * A hit on another seat is a hit — points for the damage, one more for accuracy — + * and downing one pays `PARTICIPANT_BOUNTY_MULT` times the bounty the same airframe + * carries when the squadron flies it, through the shooter's streak. The victim is + * never paid, whoever the arena blames. Both seats' views of the same kill are + * asserted from the feed: the shooter is told what it downed and what it was paid, + * the victim is told who downed it. * - * That is milestone 8's to settle and not this one's, because the answer is a - * number rather than a mechanism: `PLANS/NEON_ORBIT_PHASE_B.md` still has "AI kills - * count for less than human kills" as an open question, and a human hull has no - * bounty on its spec sheet to borrow. Inventing one here would be a balance - * decision wearing a refactor's clothes. + * Flown rather than staged: two Hornets on the autopilot with hulls the size of + * stations, the squadron unkillable and untouchable so every point in the match + * is participant-on-participant. + */ +function testShootingAParticipantPays(): void { + section('Shooting another participant pays, and the feed says who') + + const original = { + hornetHull: SHIPS.hornet.maxHull, + hornetRadius: SHIPS.hornet.radius, + waspHull: SHIPS.wasp.maxHull, + droneHull: SHIPS.drone.maxHull, + waspRadius: SHIPS.wasp.radius, + droneRadius: SHIPS.drone.radius, + waspDamage: SHIPS.wasp.damage, + droneDamage: SHIPS.drone.damage, + } + SHIPS.hornet.maxHull = 40 + SHIPS.hornet.radius = 350 + SHIPS.wasp.maxHull = 1e6 + SHIPS.drone.maxHull = 1e6 + SHIPS.wasp.radius = 0.001 + SHIPS.drone.radius = 0.001 + SHIPS.wasp.damage = 0 + SHIPS.drone.damage = 0 + + function fly(localSeat: number) { + const hud = feedRecordingHud() + const game = newMatch({ hud }) + game.start({ ships: ['hornet', 'hornet'], seed: 0x8a7e, respawn: true, local: localSeat }) + const crew = seatPilots(2) + const intents: Controls[] = [] + let last: RunSnapshot[] = [] + for (let i = 0; i < Math.ceil(30 / STEP); i++) { + const views = [game.snapshot(0), game.snapshot(1)] + if (views.some((v) => v === null)) break + last = views as RunSnapshot[] + flyAll(game, crew, intents) + } + const feed = game.capture().feed + game.dispose() + return { views: last, hud, feed } + } + + const from0 = fly(0) + const from1 = fly(1) + SHIPS.hornet.maxHull = original.hornetHull + SHIPS.hornet.radius = original.hornetRadius + SHIPS.wasp.maxHull = original.waspHull + SHIPS.drone.maxHull = original.droneHull + SHIPS.wasp.radius = original.waspRadius + SHIPS.drone.radius = original.droneRadius + SHIPS.wasp.damage = original.waspDamage + SHIPS.drone.damage = original.droneDamage + + const [zero, one] = from0.views + const deaths = (zero?.deaths ?? 0) + (one?.deaths ?? 0) + const kills = (zero?.kills ?? 0) + (one?.kills ?? 0) + check('the two seats fought each other to at least one death', from0.views.length === 2 && deaths > 0, `${deaths} deaths`) + check('every death was a kill for the other seat', deaths > 0 && kills === deaths, `${kills} kills for ${deaths} deaths`) + check('hits on a participant are hits', (zero?.hits ?? 0) + (one?.hits ?? 0) > 0, `${zero?.hits} / ${one?.hits}`) + check('and are paid', (zero?.score ?? 0) + (one?.score ?? 0) > 0, `${zero?.score} / ${one?.score}`) + + const events = from0.feed + const first = events[0] + const bounty = Math.round(SHIPS.hornet.bounty * PARTICIPANT_BOUNTY_MULT) + check('the feed carries the kill: a seat downed a seat', first !== undefined && first.killer >= 0 && first.victim >= 0 && first.killer !== first.victim && first.hull === 'hornet', + JSON.stringify(first)) + // The ring holds the *last* kills, so this may be the shooter's second or third: any + // rung of the streak is the right answer, and nothing else is. + const rungs = Array.from({ length: 8 }, (_, k) => Math.round(bounty * Math.min(3, 1 + (k + 1) * 0.25))) + check(`and the bounty is ${PARTICIPANT_BOUNTY_MULT}× the airframe's, through the shooter's streak`, + first !== undefined && rungs.includes(first.award), `${first?.award}, bounty ${bounty}, rungs ${rungs.join('/')}`) + check('the feed is capped at the ring', events.length <= FEED_RING && events.every((e, i) => i === 0 || e.seq === events[i - 1].seq + 1), + `${events.length} events, seqs ${events.map((e) => e.seq).join(',')}`) + + const killerView = first && first.killer === 0 ? from0 : from1 + const victimView = first && first.killer === 0 ? from1 : from0 + const victimName = first ? `P${first.victim + 1}` : '?' + const killerName = first ? `P${first.killer + 1}` : '?' + check('the shooter\'s feed names what it downed and what it was paid', + first !== undefined && killerView.hud.lines.includes(`${victimName} DOWN +${first.award}`) && killerView.hud.callouts.includes('TARGET DESTROYED'), + JSON.stringify(killerView.hud.lines.slice(0, 3))) + check('the victim\'s feed names who downed it', + first !== undefined && victimView.hud.lines.includes(`DOWNED BY ${killerName}`), JSON.stringify(victimView.hud.lines.slice(0, 3))) + check('and the same match was fought from both viewpoints', JSON.stringify(from0.feed) === JSON.stringify(from1.feed)) +} + +/** + * Who a mine pays: the seat that last hit the victim, and nobody else. * - * When milestone 8 does settle it, this check fails. That is the intent: it is a - * note that has to be read, not a wall. + * The README sells chasing a hostile onto a mine as a tactic, and with one seat that + * was implemented by blaming "the other side", which resolved to seat 0 whatever had + * happened. Milestone 8 makes it a rule that survives a roster: the arena's damage is + * credited to the last seat that landed a hit on the victim; a hostile nobody touched + * pays nobody — except in a match of one seat, where it pays that seat, which is the + * shipped single-player scoreline bit for bit. And a seat is never paid for its own + * death, whoever the arena blames. + * + * Two seats, one shooting and one holding station with its trigger untouched; the + * squadron cannot shoot back and cannot be killed by bolts, so every kill is the + * mine's, and every point in the shooter's line is a hit or a mine it is owed for. */ -function testShootingAParticipantScoresNothingYet(): void { - section('Shooting another participant scores nothing yet — milestone 8') +function testAMinePaysTheLastHitter(): void { + section('A mine pays the seat that last hit the victim') - const bolts = createBolts() - const ctx: ShipContext = { hazards: [], audio: silentAudio(), bolts, localFaction: FACTION_PLAYER } + const original = { + waspHull: SHIPS.wasp.maxHull, + droneHull: SHIPS.drone.maxHull, + waspRadius: SHIPS.wasp.radius, + droneRadius: SHIPS.drone.radius, + waspDamage: SHIPS.wasp.damage, + droneDamage: SHIPS.drone.damage, + hornetHull: SHIPS.hornet.maxHull, + hornetDamage: SHIPS.hornet.damage, + } + // A hostile a mine kills outright and ten seconds of Hornet fire cannot: at + // most 67 bolts of a tenth each against a hull of exactly the mine's damage. + SHIPS.wasp.maxHull = MINE_DAMAGE + SHIPS.drone.maxHull = MINE_DAMAGE + SHIPS.wasp.radius = 350 + SHIPS.drone.radius = 350 + SHIPS.wasp.damage = 0 + SHIPS.drone.damage = 0 + SHIPS.hornet.maxHull = 1e6 + SHIPS.hornet.damage = 0.1 + const SENTINEL = 350 - const seats = createSeats([SHIPS.hornet, SHIPS.wasp], 0x5c0e2) - const [alice, bob] = seats - alice.ship.spawn(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1000)) - bob.ship.spawn(new THREE.Vector3(0, 0, -300), new THREE.Vector3(0, 0, -2000)) - settle([alice.ship, bob.ship], ctx) - - const line = [alice.ship, bob.ship] - const before = bob.ship.hull - for (let i = 0; i < 400 && bob.ship.hull === before; i++) { - alice.ship.position.set(0, 0, 0) - alice.ship.velocity.set(0, 0, 0) - bob.ship.position.set(0, 0, -300) - bob.ship.velocity.set(0, 0, 0) - alice.ship.step(controls({ fire: true }), STEP, ctx) - bolts.update(STEP, line, []) - } - - check('a seat can shoot another seat', bob.ship.hull < before, `hull ${bob.ship.hull}/${before}`) - check('the shooter fired', alice.ship.shotsFired > 0) - check( - 'and the hit paid nothing — milestone 8 decides what a participant is worth', - alice.score === 0 && alice.hits === 0, - `score ${alice.score}, hits ${alice.hits}`, - ) + /** + * Two waves. The squadron warps in three at a time, so the first three are + * mined before anybody has fired — nobody has touched them — and the next + * three are worked over by the shooter for ten seconds first. + */ + function fly(ships: ShipId[], shooter: number | null) { + const field = aimedMinefield() + const game = newMatch({ environment: { ...stubEnvironment(), minefield: field } }) + game.start({ ships, seed: 0x3a5e, respawn: true }) + const crew = seatPilots(ships.length) + const intents: Controls[] = [] + const idle = () => ships.map(() => controls({ throttle: 0.3 })) + const line = () => ships.map((_, i) => ({ ...game.snapshot(i)! })) - bolts.dispose() - for (const seat of seats) seat.ship.dispose() + function waitForWave(): number { + for (let i = 0; i < Math.ceil(40 / STEP); i++) { + if ((game.snapshot(0)?.enemiesAirborne ?? 0) >= 3) break + game.step(idle()) + } + return game.snapshot(0)?.enemiesAirborne ?? 0 + } + const remaining = () => (game.snapshot(0)?.enemiesAirborne ?? 0) + (game.snapshot(0)?.enemiesQueued ?? 0) + /** Mine until the squadron is down to `until` hulls, airborne or waiting. */ + function mine(until: number): void { + field.aim((r) => r === SENTINEL) + for (let i = 0; i < 200 && remaining() > until; i++) { + field.arm() + game.step(idle()) + } + field.aim(() => false) + } + + const wave1 = waitForWave() + mine(3) + const untouched = { line: line(), feed: game.capture().feed.map((e) => e.killer), airborne: remaining() } + + const wave2 = waitForWave() + for (let i = 0; i < Math.ceil(10 / STEP); i++) { + for (let j = 0; j < crew.length; j++) { + steer(crew[j].device, j === shooter ? (game.snapshot(j)?.target ?? null) : null) + intents[j] = crew[j].pilot.advance(crew[j].device.state, STEP) + if (j !== shooter) intents[j] = controls({ ...intents[j], fire: false }) + } + game.step(intents) + } + const shot = line() + // To one, not none: the last kill clears the squadron, `finish` clears the + // arena, and there is no line left to read. See `matchPrint`'s note. + mine(1) + const worked = { line: line(), airborne: remaining() } + game.dispose() + return { wave1, wave2, untouched, shot, worked } + } + + const two = fly(['hornet', 'hornet'], 1) + check('two seats: a wave of three was mined before anybody fired', two.wave1 === 3 && two.untouched.airborne === 3 && two.untouched.line.every((s) => s.shotsFired === 0), + `${two.wave1} airborne, ${two.untouched.airborne} left of 6; shots ${two.untouched.line.map((s) => s.shotsFired).join('/')}`) + check('and paid nobody: the feed blames the arena', two.untouched.feed.length === 3 && two.untouched.feed.every((k) => k === THE_ARENA) && + two.untouched.line.every((s) => s.kills === 0 && s.score === 0), + `killers ${two.untouched.feed.join(',')}; kills ${two.untouched.line.map((s) => s.kills).join('/')}, scores ${two.untouched.line.map((s) => s.score).join('/')}`) + check('the second wave was worked over by one seat', two.wave2 === 3 && two.shot[1].hits > 0 && two.shot[1].kills === 0 && two.shot[0].shotsFired === 0, + `${two.shot[1].hits} hits by seat 1, seat 0 fired ${two.shot[0].shotsFired}`) + check('and when the mine took it, the shooter was paid for what it had hit', two.worked.airborne === 1 && two.worked.line[1].kills > 0 && two.worked.line[1].score > two.shot[1].score, + `${two.worked.line[1].kills} kills, ${two.shot[1].score} -> ${two.worked.line[1].score}`) + check('and the idle seat, which hit nothing, for nothing', two.worked.line[0].kills === 0 && two.worked.line[0].score === 0 && two.worked.line[0].hits === 0, + `${two.worked.line[0].kills} kills, score ${two.worked.line[0].score}`) + + const one = fly(['hornet'], null) + check('one seat: the untouched wave still pays the sole seat — the shipped rule', one.wave1 === 3 && one.untouched.airborne === 3 && + one.untouched.line[0].kills === 3 && one.untouched.line[0].hits === 0 && one.untouched.line[0].score > 0, + `${one.untouched.line[0].kills} kills, ${one.untouched.line[0].hits} hits, score ${one.untouched.line[0].score}`) + check('and so does the second, without a shot fired', one.worked.airborne === 1 && one.worked.line[0].kills === 5 && one.worked.line[0].shotsFired === 0, + `${one.worked.line[0].kills} kills, ${one.worked.line[0].shotsFired} shots`) + + SHIPS.wasp.maxHull = original.waspHull + SHIPS.drone.maxHull = original.droneHull + SHIPS.wasp.radius = original.waspRadius + SHIPS.drone.radius = original.droneRadius + SHIPS.wasp.damage = original.waspDamage + SHIPS.drone.damage = original.droneDamage + SHIPS.hornet.maxHull = original.hornetHull + SHIPS.hornet.damage = original.hornetDamage } /** @@ -4991,15 +5203,14 @@ function testAnEliminatedSeatDoesNotInheritTheWin(): void { ) /* * Exactly the score it had when it died, and the assertion is the *equality* rather - * than a bound. A threshold was the first attempt and it was a bad proxy: this seat - * legitimately earned 1813 points, because unattributable kills fall back to seat 0 - * and the mine clearing the squadron produced a lot of them — so "the score is small" - * says nothing about whether a bonus was added. + * than a bound. A threshold was the first attempt and it was a bad proxy. * - * The freeze is load-bearing here, which the second check establishes: the seat keeps - * being credited after it is dead, for exactly the reason `sealResult` exists — "long - * enough for a hostile to fly into the star and post a bounty to a pilot who is - * already dead". + * Until milestone 8 this seat went on earning after it was dead — 1813 points, because + * unattributable kills fell back to seat 0 and the mine clearing the squadron produced + * a lot of them — and the freeze was what kept them out of the report. The rule now is + * that a kill with no author pays the last seat to land a hit on the victim, and this + * seat never fired, so the second check pins the other half: a dead seat that touched + * nothing is credited nothing. */ check( 'and its result is the scoreline it died with', @@ -5007,8 +5218,8 @@ function testAnEliminatedSeatDoesNotInheritTheWin(): void { `reported ${asVictim.result?.score}, had ${asVictim.scoreAtDeath} at death`, ) check( - 'which is not the same as its scoreline at the end — the seal is doing work', - asVictim.scoreAtEnd > asVictim.scoreAtDeath, + 'and mines clearing the squadron after its death paid a seat that never fired nothing', + asVictim.scoreAtEnd === asVictim.scoreAtDeath && asVictim.scoreAtDeath === 0, `${asVictim.scoreAtDeath} at death, ${asVictim.scoreAtEnd} when the match ended`, ) /* The other viewpoint, from the same match: the survivor really did win, so the check @@ -6752,7 +6963,8 @@ testTheDevHookReadsTheRunningGame() testTheLoopSurvivesTheEndOfARun() testScoringIsPerSeat() testTwoScorersKeepSeparateStreaks() -testShootingAParticipantScoresNothingYet() +testShootingAParticipantPays() +testAMinePaysTheLastHitter() testTheStepClockNeverLosesTime() testARunMatchesItsRecordedBaseline() testOneFrameDepictsOneInstant() diff --git a/src/game/bolts.ts b/src/game/bolts.ts index 373099e..5c94455 100644 --- a/src/game/bolts.ts +++ b/src/game/bolts.ts @@ -55,6 +55,18 @@ export const FACTION_AI = -1 as unknown as Faction */ export const FACTION_PLAYER = 0 as unknown as Faction +/** + * The arena itself: a mine, a station scrape. Damage with no author. + * + * Below the AI so it is nobody's seat and nobody's squadron — `seatOf` misses + * it and the friendly-fire rule never sees it. Who is *credited* for it is a + * match rule (`game.ts`, `bountyGoesTo`): the seat that last landed a hit on + * the victim, so a hostile chased onto a mine still scores for the chaser, and + * in a match of one seat, that seat — which is what the old "blame the other + * side" produced, and is now said rather than derived. + */ +export const FACTION_ENVIRONMENT = -2 as unknown as Faction + /** * The faction for the human at `index` in the roster. Zero is the local player * today; PvP hands out 1, 2, … as participants join. diff --git a/src/game/game.ts b/src/game/game.ts index c513b69..0286d2c 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -44,19 +44,19 @@ import { type PickupKind, } from '../world/pickups' import { EnemyPilot } from './ai' -import { createBolts, FACTION_AI, FACTION_PLAYER, type Bolts, type Faction } from './bolts' -import type { LockRef, SeatState, ShipState, SquadronState, WorldSnapshot } from '../net/snapshot' +import { createBolts, FACTION_AI, FACTION_ENVIRONMENT, FACTION_PLAYER, type Bolts, type Faction } from './bolts' +import { FEED_RING, NOBODY, THE_ARENA, type KillEvent, type LockRef, type SeatState, type ShipState, type SquadronState, type WorldSnapshot } from '../net/snapshot' import { createChaseCamera, type ChaseCamera } from './chase' import { createFx, type Fx } from './fx' import type { Hud, HudContact, HudTarget } from './hud' import { accuracyOf, createSeats, + creditDamage, creditHit, creditKill, ELIMINATED, FLYING, - isParticipant, launchPoint, recordControls, seatOf, @@ -81,6 +81,16 @@ import { Ship, type Controls, type ShipContext } from './ship' */ export const STEP = 1 / 60 +/** + * What a participant's hull is worth to whoever downs it, as a multiple of the + * bounty the same airframe carries when the squadron flies it. + * + * A number, not a mechanism, and it is here so that it is one number: a human + * on the stick is harder to hit than the scripted pilot, and the match should + * pay for it. Two is a first guess and a balance lever, not a finding. + */ +export const PARTICIPANT_BOUNTY_MULT = 2 + /** Hulls of each non-chosen type that make up the squadron. */ const PER_ENEMY_TYPE = 3 /** How many enemies are airborne at once. */ @@ -492,6 +502,16 @@ export function createGame(deps: GameDeps): Game { */ let mirrored = false let mirroredQueued = 0 + /** + * The seat that last landed a hit on each hull, for damage with no author. + * Weak, so a retired squadron hull takes its entry with it. + */ + const lastHitter = new WeakMap() + /** The latest kills, oldest first, at most `FEED_RING`. Sent with every snapshot. */ + let feed: KillEvent[] = [] + let feedSeq = 0 + /** A mirror: the last `KillEvent.seq` announced. */ + let feedSeen = 0 /** Spawn order of each squadron hull — the identity a snapshot carries for it. */ const pilotIds = new Map() /** @@ -636,26 +656,79 @@ export function createGame(deps: GameDeps): Game { } /** - * Which seat is paid for a hostile going down. + * The seat a hit is credited to, or nobody. * - * A hit has an author or it has none, and `onDamaged` treats "none" as nobody - * scoring. A *kill* cannot do that without changing the game: sear is - * self-attributed on purpose — crediting it would count every burn tick as a - * shot landed and destroy the accuracy stat — and a mine has no faction at all, - * yet baiting a hostile into either still clears it from the squadron and still - * pays out. The README sells the mine version as a tactic. + * A bolt names its author, and that is the credit. Damage the arena inflicts + * — a mine, a scrape — arrives as `FACTION_ENVIRONMENT`, and goes to the seat + * that last landed a hit on the victim: a hostile chased onto a mine still + * scores for the chaser, which the README sells as a tactic. In a match of + * one seat, the arena's damage to a hostile is that seat's — exactly what the + * old "blame the other side" produced there — so the single-player scoreline + * is unchanged bit for bit. With more seats and no last hitter it is nobody's. + * The star names the victim's own faction and is never a hit for anyone. + */ + function hitCredit(from: Faction, victim: Ship): Participant | null { + if (from === FACTION_ENVIRONMENT) return lastHitter.get(victim) ?? soleSeat() + return seatOf(seats, from) ?? null + } + + /** + * The seat paid for a kill, or nobody. * - * So an unattributable kill falls back to seat 0. With one seat that is exactly - * today's behaviour on every path, bit for bit, because seat 0 is the only - * scoreline there is. **With more than one it is arbitrary and wrong**, and it - * is written this way rather than fixed because fixing it means deciding - * whether environment kills count at all and who gets them — a match rule, and - * milestone 8 owns match rules. `notMe` in `ship.ts` carries the other half of - * the same deferral. What this must never become is a rule invented here and - * then inherited as if it had been chosen. + * The author if it has one, else the last seat to land a hit, else — in a + * match of one seat only — that seat. That last clause is the whole of the + * single-player behaviour, in which every kill was seat 0's: a hostile that + * flew into the star untouched still paid. With more than one seat it is + * no longer arbitrary: an untouched hostile burning up pays nobody. + */ + function bountyGoesTo(from: Faction, victim: Ship): Participant | null { + return seatOf(seats, from) ?? lastHitter.get(victim) ?? soleSeat() + } + + /** The one seat, in a match of one; nobody otherwise. */ + function soleSeat(): Participant | null { + return seats.length === 1 ? seats[0] : null + } + + /* ---- The kill feed ------------------------------------------------------ */ + + /** A seat by name for the feed: the watcher is YOU, everyone else is P. */ + function seatName(index: number): string { + return seats[index] === local() ? 'YOU' : `P${index + 1}` + } + + /** + * Show one kill to the seat being drawn. The host runs this as the kill + * happens; a mirror runs it as the event arrives in a snapshot. Same text on + * every machine, from the same event, with only YOU moving. */ - function bountyGoesTo(from: Faction): Participant | null { - return seatOf(seats, from) ?? seats[0] ?? null + function announceKill(e: KillEvent): void { + const watcher = local() + const victimName = e.victim >= 0 ? seatName(e.victim) : SHIPS[e.hull].name.toUpperCase() + const killerName = e.killer >= 0 ? seatName(e.killer) : e.killer === THE_ARENA ? 'THE ARENA' : 'THE SQUADRON' + if (e.killer >= 0 && seats[e.killer] === watcher) { + hud.feed(`${victimName} DOWN +${e.award}`) + hud.callout('TARGET DESTROYED', `#${SHIPS[e.hull].accent.toString(16).padStart(6, '0')}`, 1.1) + } else if (e.victim >= 0 && seats[e.victim] === watcher) { + hud.feed(`DOWNED BY ${killerName}`) + } else { + hud.feed(`${killerName} ▸ ${victimName} DOWN`) + } + } + + /** Record a kill for the wire and announce it here. */ + function recordKill(killer: Participant | null, from: Faction, victim: Participant | null, hull: ShipId, award: number): void { + const e: KillEvent = { + seq: ++feedSeq, + killer: killer ? killer.index : from === FACTION_AI ? NOBODY : THE_ARENA, + victim: victim ? victim.index : NOBODY, + hull, + award, + } + feed.push(e) + if (feed.length > FEED_RING) feed.shift() + feedSeen = e.seq + announceKill(e) } /* ------------------------------------------------------------------------ */ @@ -715,27 +788,25 @@ export function createGame(deps: GameDeps): Game { // A hit is credited to whoever landed it, and to nobody when that is nobody. // `seatOf` is the lookup that makes this safe — resolving faction to seat and // returning nothing on a miss, rather than minting a faction from a search. - ship.onDamaged = (_self, amount, from) => { - const scorer = seatOf(seats, from) - if (!scorer) return - creditHit(scorer, amount) + ship.onDamaged = (self, amount, from) => { + const direct = seatOf(seats, from) + if (direct) { + lastHitter.set(self, direct) + creditHit(direct, amount) + return + } + const owed = hitCredit(from, self) + if (owed) creditDamage(owed, amount) } ship.onDeath = (self, from) => { - const scorer = bountyGoesTo(from) + const scorer = bountyGoesTo(from, self) const award = scorer ? creditKill(scorer, self.spec.bounty) : 0 fx.explode(self.position, self.accent, self.spec.id === 'drone' ? 1.5 : 1.1) audio.explosion(self.spec.id === 'drone') const watcher = local() chase.shake(watcher && self.position.distanceTo(watcher.ship.position) < 420 ? 0.8 : 0.25) - // Announced to the seat that was paid, and to nobody else. A kill feed - // carrying other participants' kills is milestone 8; "TARGET DESTROYED" is - // about *your* target, and showing it for someone else's shot is a lie the - // single-player HUD never had to tell. - if (scorer === watcher) { - hud.feed(`${self.spec.name.toUpperCase()} DOWN +${award}`) - hud.callout('TARGET DESTROYED', `#${self.spec.accent.toString(16).padStart(6, '0')}`, 1.1) - } + recordKill(scorer, from, null, self.spec.id, award) } scene.add(ship.visual.group) @@ -796,19 +867,11 @@ export function createGame(deps: GameDeps): Game { } if (watcher && target === watcher.ship) hud.callout('MINE', '#ff3b4e', 1.2) - // Attributed to "not the victim" so a hostile chased onto a mine scores - // for a participant — documented behaviour, and the one place the arena has - // to name a culprit it does not have. See `notMe` in `ship.ts` and - // `bountyGoesTo` above: this is the shape that stops working once there are - // more than two factions, and it is deliberately still that shape. - // - // Which seat it names is now a lookup rather than a constant, and it is the - // *victim's* seat that decides: a participant on a mine is blamed on the AI - // so nobody profits from it, and a hostile on a mine is blamed on - // `FACTION_PLAYER`, which `bountyGoesTo` resolves to seat 0. Identical to - // the old ternary for one seat, and arbitrary for more than one. - const victimIsSeat = isParticipant(seats, target.faction) - target.takeDamage(MINE_DAMAGE, victimIsSeat ? FACTION_AI : FACTION_PLAYER) + // The arena's doing. Who is credited is `hitCredit` / `bountyGoesTo`'s + // rule: the last seat to land a hit on the victim, so a hostile chased + // onto a mine scores for the chaser and a participant on a mine pays + // whoever was on their tail. + target.takeDamage(MINE_DAMAGE, FACTION_ENVIRONMENT) } } @@ -1187,6 +1250,7 @@ export function createGame(deps: GameDeps): Game { function respawnSeat(seat: Participant): void { seat.phase = FLYING seat.lockedTarget = null + lastHitter.delete(seat.ship) pickRespawnPoint(seat, _spawnPos) fightCentre(_anchor) seat.ship.spawn(_spawnPos, _anchor) @@ -1537,6 +1601,7 @@ export function createGame(deps: GameDeps): Game { paused, queued: queuedCount(), seats: seatStates, + feed: feed.map((e) => ({ ...e })), squadron: squadronStates, bolts: boltStates, pods: environment.pickups.pods.map((pod) => ({ live: pod.live, respawnIn: pod.respawnIn })), @@ -1664,6 +1729,17 @@ export function createGame(deps: GameDeps): Game { // Locks resolve after every hull exists. for (let i = 0; i < seats.length; i++) seats[i].lockedTarget = resolveLock(s.seats[i].lock) + // The feed: whatever this snapshot carries that has not been shown. Kept + // as the host's ring so a re-capture is the host's bytes. + for (const e of s.feed) { + if (e.seq > feedSeen) { + feedSeen = e.seq + announceKill(e) + } + } + feed = s.feed.map((e) => ({ ...e })) + feedSeq = feed.length > 0 ? feed[feed.length - 1].seq : feedSeq + bolts.restore(s.bolts) const pods = environment.pickups.pods @@ -2123,6 +2199,9 @@ export function createGame(deps: GameDeps): Game { seats = built acks = seats.map(() => -1) localIndex = drawnSeatIndex(setup.local, seats.length) + feed = [] + feedSeq = 0 + feedSeen = 0 ctx.localFaction = seats[localIndex].faction for (const seat of seats) { @@ -2130,14 +2209,29 @@ export function createGame(deps: GameDeps): Game { launchPoint(seat.index, seats.length, _spawnPos) ship.spawn(_spawnPos, PLAYER_SPAWN_LOOK) - // Feedback is the drawn seat's, and only the drawn seat's. Another - // participant being hit shakes their camera, on their machine. - ship.onDamaged = (_self, amount) => { + // A participant's hull pays like any other: the hit to whoever landed it, + // the kill to whoever is owed it (`bountyGoesTo`), at `PARTICIPANT_BOUNTY_MULT` + // times the airframe's bounty. Never to the victim, whoever the arena + // blames: a seat cannot profit from its own death. + ship.onDamaged = (self, amount, from) => { + const direct = seatOf(seats, from) + if (direct && direct !== seat) { + lastHitter.set(self, direct) + creditHit(direct, amount) + } + // Feedback is the drawn seat's, and only the drawn seat's. Another + // participant being hit shakes their camera, on their machine. if (seat !== local()) return hud.flashDamage() audio.hullHit() chase.shake(Math.min(1.6, 0.25 + amount * 0.02)) } + ship.onDeath = (self, from) => { + const owed = bountyGoesTo(from, self) + const scorer = owed && owed !== seat ? owed : null + const award = scorer ? creditKill(scorer, Math.round(self.spec.bounty * PARTICIPANT_BOUNTY_MULT)) : 0 + recordKill(scorer, from, seat, self.spec.id, award) + } // A shielded hit has to feel like *something* or the player cannot tell // the shield from a lull in enemy fire. Deliberately a much smaller nudge // than a hull hit, and no red flash: this is the good outcome. diff --git a/src/game/roster.ts b/src/game/roster.ts index 4042296..4c1e776 100644 --- a/src/game/roster.ts +++ b/src/game/roster.ts @@ -249,7 +249,7 @@ export function recordControls(seat: Participant, c: Controls): void { } /** - * Credit a landed hit. + * Credit a landed bolt: the damage as points, and one more hit for accuracy. * * The bounty for the kill is separate — see `creditKill` — because the two have * different answers to "and if nobody did it": a hit with no author scores @@ -257,6 +257,15 @@ export function recordControls(seat: Participant, c: Controls): void { */ export function creditHit(seat: Participant, amount: number): void { seat.hits++ + creditDamage(seat, amount) +} + +/** + * Credit damage that was not a bolt — a mine or a scrape the seat is owed + * for (`hitCredit` in `game.ts`). Points, but not a hit: accuracy is bolts + * landed over bolts fired, and a mine is neither. + */ +export function creditDamage(seat: Participant, amount: number): void { seat.score += Math.round(amount) } diff --git a/src/game/ship.ts b/src/game/ship.ts index 5ae8064..93651d0 100644 --- a/src/game/ship.ts +++ b/src/game/ship.ts @@ -24,7 +24,7 @@ import { type Hazard, } from '../world/environment' import { OVERDRIVE_RATE_MULT } from '../world/pickups' -import { FACTION_AI, FACTION_PLAYER, type BoltTarget, type Bolts, type Faction } from './bolts' +import { FACTION_ENVIRONMENT, type BoltTarget, type Bolts, type Faction } from './bolts' import { bound } from './intent' export interface Controls { @@ -508,9 +508,8 @@ export class Ship implements BoltTarget { // Kill the inward component and bounce back a little. this.velocity.addScaledVector(_normal, -closing * 1.5) const impact = Math.abs(closing) - // Blamed on "somebody else", which is the one shape that does not - // survive more than two factions — see `NOT_ME` below. - this.takeDamage(Math.min(55, 4 + impact * 0.1), notMe(this.faction)) + // The arena's doing; who is credited for it is the match's rule. + this.takeDamage(Math.min(55, 4 + impact * 0.1), FACTION_ENVIRONMENT) this.onCollide?.(this, impact) } } @@ -763,21 +762,3 @@ export class Ship implements BoltTarget { } } -/** - * Someone other than `faction`, for damage the arena inflicts on its own. - * - * A station scrape and a mine have no faction, but `takeDamage` wants one, and - * blaming the victim would let a ship credit itself. With two sides "the other - * one" was well defined. With N it is not, and this function is where that - * shows — it can only answer for the two factions that exist today. - * - * Deliberately preserved rather than fixed. This is what makes chasing a - * hostile onto a mine score for you, which the README sells as a tactic, so - * changing it here would be a balance change wearing a refactor's clothes. The - * real answer is an environment faction plus a scoring rule deciding whether - * environment kills count — a match-rules decision, milestone 8 in - * `PLANS/NEON_ORBIT_PHASE_B.md`. - */ -function notMe(faction: Faction): Faction { - return faction === FACTION_PLAYER ? FACTION_AI : FACTION_PLAYER -} diff --git a/src/net/snapshot.ts b/src/net/snapshot.ts index 3f50b37..9442240 100644 --- a/src/net/snapshot.ts +++ b/src/net/snapshot.ts @@ -28,7 +28,7 @@ import { FACTION_AI, type Faction } from '../game/bolts' import { SHIP_ORDER, type ShipId } from '../ships/specs' import { ByteReader, ByteWriter } from './wire' -export const SNAPSHOT_VERSION = 1 +export const SNAPSHOT_VERSION = 2 export interface Vec3 { x: number @@ -108,6 +108,31 @@ export interface BoltState { color: Vec3 } +/** + * A kill, as the match announces it. + * + * `killer` and `victim` are seat indices, or `NOBODY` for a squadron hull and + * `THE_ARENA` for a mine, a scrape or the star. `hull` is the victim's + * airframe. `seq` counts up from one per match, which is what lets a mirror + * show each kill exactly once from a stream that loses and reorders + * snapshots: the snapshot carries the last `FEED_RING` events, and a mirror + * announces those with a `seq` above the last it announced. + */ +export interface KillEvent { + seq: number + killer: number + victim: number + hull: ShipId + award: number +} + +/** A `KillEvent` party that is a squadron hull rather than a seat. */ +export const NOBODY = -1 +/** A `KillEvent` killer that is the arena: a mine, a scrape, the star. */ +export const THE_ARENA = -2 +/** How many of the latest kills a snapshot carries. */ +export const FEED_RING = 4 + export interface PodState { live: boolean respawnIn: number @@ -126,6 +151,8 @@ export interface WorldSnapshot { bolts: BoltState[] pods: PodState[] mines: boolean[] + /** The latest kills, oldest first, at most `FEED_RING`. */ + feed: KillEvent[] } /* ---- Encoding ------------------------------------------------------------ */ @@ -223,6 +250,9 @@ export function encodeSnapshot(s: WorldSnapshot, w = new ByteWriter(4096)): Uint w.u16(s.mines.length) for (const live of s.mines) w.bool(live) + w.u8(s.feed.length) + for (const e of s.feed) w.u32(e.seq).i32(e.killer).i32(e.victim).u8(SHIP_ORDER.indexOf(e.hull)).i32(e.award) + return w.bytes() } @@ -296,8 +326,22 @@ export function decodeSnapshot(bytes: Uint8Array): WorldSnapshot { const mineCount = r.u16() for (let i = 0; i < mineCount; i++) mines.push(r.bool()) + const feed: KillEvent[] = [] + const feedCount = r.u8() + if (feedCount > FEED_RING) throw new RangeError(`${feedCount} kills in a feed of ${FEED_RING}`) + for (let i = 0; i < feedCount; i++) { + const seq = r.u32() + const killer = r.i32() + const victim = r.i32() + const hullIndex = r.u8() + const hull = SHIP_ORDER[hullIndex] + if (!hull) throw new RangeError(`unknown hull ${hullIndex} in the feed`) + const award = r.i32() + feed.push({ seq, killer, victim, hull, award }) + } + r.finish() - return { tick, seed, elapsed, active, paused, queued, seats, squadron, bolts, pods, mines } + return { tick, seed, elapsed, active, paused, queued, seats, squadron, bolts, pods, mines, feed } } /** A faction that is nobody's seat, for a bolt whose owner the mirror cannot see. */ From 0a2492229b18c67ec4db2fbb247aa4e0b2053288 Mon Sep 17 00:00:00 2001 From: Stephen DeLorme Date: Wed, 2 Sep 2026 17:51:59 -0400 Subject: [PATCH 2/3] End the match on every machine, with one scoreboard Milestone 8, second half: how a match ends when there is more than one seat, and telling the mirror. `sealMatch` is the rule, in one place. On a cleared squadron every seat still flying is paid for finishing intact (hull fraction x 1200) and fast (4000 - 25/s, floored at zero) -- for one seat exactly the win bonus the debrief always showed, so the single-player result is unchanged. Seats are then placed by score (`placeLines`): equal scores share a place, an eliminated seat places after every flying one whatever it scored, and the seats placed first and still flying have won -- a match that emptied the arena has a placing and nobody flying, so no winner. The time bonus is the same for everyone and cannot move the placing; the hull bonus can, and is meant to. `RunResult` carries the `MatchResult` when there was more than one seat. The host keeps the result (`Game.result`) and `createHost` sends it to every peer in a RESULT frame on the first tick after the match resolved and every half second after, for as long as it ticks: the wire drops frames, and a result that never arrived is a debrief the joiner never sees. No snapshot is sent on the resolving tick or after, since `finish` has cleared the roster and a snapshot of nobody is not a world. The mirror decodes it (short, long, foreign or mistyped frames refused with a RangeError), drops whatever snapshots it was still holding, and `Game.conclude`s: arena cleared, `onEnd` with its own seat's line; a repeat after that is ignored. The debrief shows the board on both machines -- place, seat, hull, score, kills/deaths/accuracy, the local seat lit -- and a seat that was alive and outscored reads OUTSCORED rather than HULL BREACH. Checks: the placing rules directly (ties, the eliminated, an emptied arena); the frame codec; and a two-seat match over the loopback where a mine dents the host's own hull before the squadron is cleared: both machines report the match ended with the same lines, the peer was told once, the dented host placed second and did not win, the gap between the two scores is exactly the hull the host was missing, and no snapshot followed the result. The same match over a wire that loses half of everything still concludes on the mirror, because the result was said again. The sealed-loss check reads the eliminated seat's line off the board: last, unpaid for finishing, not the winner. The stale sealResult commentary that deferred all of this is gone. The first cut of the mutation gate for this branch left four mutants alive, and each was a test gap rather than dead code: the arena's damage paying the sole seat points, a sole seat being paid for its own death, a scrape blamed on "the other side", and the eliminated being paid the finishing bonus. Each has a check now; a fifth mutant (`cleared &&` on the win) was equivalent, and the flag is gone. Simulation checks 584 -> 607, seven new mutations. Co-authored-by: Claude Fable 5.1 Signed-off-by: Stephen DeLorme --- README.md | 25 ++++- scripts/mutate.mjs | 46 ++++++++- scripts/simcheck.ts | 238 ++++++++++++++++++++++++++++++++++++++++++-- src/core/scores.ts | 38 +++++++ src/game/game.ts | 160 +++++++++++++++++++++-------- src/net/session.ts | 92 ++++++++++++++++- src/style.css | 42 ++++++++ src/ui/panels.ts | 33 +++++- 8 files changed, 619 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 9e9952a..b116150 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,9 @@ threading a habitat ring at full throttle stays available. Hitting a core scrape bounces you off. **Mines** are the red spiky ones. Twenty-six of them, scattered on a fixed seed, and they take -45 hull off anything that touches them — you, or a hostile that gets chased into one. That is -survivable in every airframe but costs a Wasp two thirds of its hull. Enemies steer around them, +45 hull off anything that touches them — you, or a hostile that gets chased into one, which pays +whoever last landed a hit on it. That is survivable in every airframe but costs a Wasp two thirds +of its hull. Enemies steer around them, but only loosely, so pressure can still push one onto a mine. They detonate once and stay gone for the rest of the run. @@ -249,6 +250,26 @@ on a quarter of the client's ticks and jumps on a fifth; paced, never either, wh applied is still the host's world byte for byte, a coasted tick is within 0.005 units of the snapshot it stood in for, and the queue settles three deep and stays there. +**Every kill has an owner, and the match has a scoreboard.** A bolt names its author, and that +is the credit. Damage the arena inflicts — a mine, a station scrape — carries a faction of its own +(`FACTION_ENVIRONMENT`) and is credited to the seat that last landed a hit on the victim, so a +hostile chased onto a mine still scores for the chaser; in a match of one seat, that seat, which is +the shipped scoreline bit for bit; with more seats and no last hitter, nobody. Arena damage pays +points but not a hit, because accuracy is bolts landed over bolts fired. A participant's hull pays +like any other, at `PARTICIPANT_BOUNTY_MULT` (two) times the airframe's bounty, and never to the +victim. Every kill is a `KillEvent` the snapshot carries the last four of, so every machine shows +the same feed from the same events with only YOU moving: the shooter reads `P2 DOWN +300`, the +victim `DOWNED BY P1`, everyone else `P1 ▸ P2 DOWN`. When the squadron is cleared the host seals +a `MatchResult` (`sealMatch`): every seat still flying is paid for finishing intact and fast — +exactly the single-player win bonus — seats are placed by score with ties shared and the +eliminated after every flying seat, the seats placed first have won, and the whole thing goes to +every peer in one RESULT frame. Both debriefs show the same board; a seat that was alive and +outscored reads OUTSCORED rather than HULL BREACH. `simcheck` flies two Hornets to a death and +reads both feeds, mines one untouched wave (pays nobody; in a match of one, pays that seat) and one +a single shooter had worked over (pays the shooter), and resolves a two-seat match over the +loopback with the host's hull dented first: the mirror gets the same lines, the dented host places +second, and no snapshot follows the result. + **Death is a per-seat state, and respawn is a match policy.** A seat is `flying`, `wrecked` or `eliminated` — one field with three shapes, because the version that used a nullable wreck meant "never died" and "died, cutscene over" with the same value and so restarted an eliminated seat's diff --git a/scripts/mutate.mjs b/scripts/mutate.mjs index bdccb9c..19797fb 100644 --- a/scripts/mutate.mjs +++ b/scripts/mutate.mjs @@ -355,6 +355,50 @@ const MUTATIONS = [ to: ' this.takeDamage(Math.min(55, 4 + impact * 0.1), (this.faction === 0 ? -1 : 0) as Faction)', }, + /* ---- Match rules: the result ------------------------------------------- */ + { + name: 'an eliminated seat places by score like a flying one', + file: 'src/game/game.ts', + from: ' const order = lines.slice().sort((a, b) => (a.alive === b.alive ? b.score - a.score : a.alive ? -1 : 1))', + to: ' const order = lines.slice().sort((a, b) => b.score - a.score)', + }, + { + name: 'equal scores do not share a place', + file: 'src/game/game.ts', + from: ' if (!prev || prev.alive !== line.alive || prev.score !== line.score) place = i + 1', + to: ' place = i + 1', + }, + { + name: 'the finishing bonus is paid to the eliminated too', + file: 'src/game/game.ts', + from: ' const bonus = cleared && alive ? Math.round(seat.ship.hullFraction * 1200) + timeBonus : 0', + to: ' const bonus = cleared ? Math.round(seat.ship.hullFraction * 1200) + timeBonus : 0', + }, + { + name: 'the host never tells its peers the match ended', + file: 'src/net/session.ts', + from: ' if (result && result !== resultSent) {', + to: ' if (false) {', + }, + { + name: 'the host snapshots a roster of nobody on the resolving tick', + file: 'src/net/session.ts', + from: ' if (++sinceSnapshot >= snapshotEvery && game.active) {', + to: ' if (++sinceSnapshot >= snapshotEvery) {', + }, + { + name: 'a mirror ignores the result', + file: 'src/net/session.ts', + from: ' queue.length = 0\n game.conclude(result)', + to: ' queue.length = 0', + }, + { + name: 'a mirror reports seat 0 whatever seat it flies', + file: 'src/game/game.ts', + from: ' const line = mine ? result.lines.find((l) => l.seat === mine.index) : undefined\n lastResult = result', + to: ' const line = result.lines[0]\n lastResult = result', + }, + /* ---- Snapshot pacing ----------------------------------------------------- */ { name: 'the client applies a snapshot the tick it arrives', @@ -751,7 +795,7 @@ function runSuite() { * If you added or removed checks on purpose, bump this in the same commit. If you did not, * something stopped running. */ -const EXPECTED_ASSERTIONS = 584 +const EXPECTED_ASSERTIONS = 607 const PASS_SUMMARY = 'All checks passed.' const SUMMARY = /check\(s\) failed\.$|All checks passed\.$/ diff --git a/scripts/simcheck.ts b/scripts/simcheck.ts index 095e345..233395f 100644 --- a/scripts/simcheck.ts +++ b/scripts/simcheck.ts @@ -14,8 +14,8 @@ import * as THREE from 'three' import type { Audio } from '../src/core/audio' import type { Input, InputState } from '../src/core/input' -import type { RunResult } from '../src/core/scores' -import { createBolts, FACTION_AI, FACTION_PLAYER, humanFaction } from '../src/game/bolts' +import type { MatchResult, RunResult, SeatLine } from '../src/core/scores' +import { createBolts, FACTION_AI, FACTION_ENVIRONMENT, FACTION_PLAYER, humanFaction, type Faction } from '../src/game/bolts' import { createStepClock } from '../src/core/loop' import { createPilot, type Pilot } from '../src/game/controls' import { admitIntent, bound, rampThrottle, THROTTLE_DOWN_RATE, THROTTLE_UP_RATE } from '../src/game/intent' @@ -32,8 +32,8 @@ import { import { decodeIntent, encodeIntent, INTENT_FRAME_BYTES, INTENT_VERSION } from '../src/net/wire' import { createLoopback } from '../src/net/channel' import { modeFromLocation } from '../src/net/browser' -import { createClient, createHost, decodeWelcome, encodeWelcome, FRAME, SNAPSHOT_DEPTH, SNAPSHOT_QUEUE } from '../src/net/session' -import { createGame, DEATH_SEQUENCE, PARTICIPANT_BOUNTY_MULT, type Game, type GameDeps, type RunSnapshot } from '../src/game/game' +import { createClient, createHost, decodeResult, decodeWelcome, encodeResult, encodeWelcome, FRAME, SNAPSHOT_DEPTH, SNAPSHOT_QUEUE } from '../src/net/session' +import { createGame, DEATH_SEQUENCE, PARTICIPANT_BOUNTY_MULT, placeLines, type Game, type GameDeps, type RunSnapshot } from '../src/game/game' import { barBrightness, DAMAGE_BAR_FADE, DAMAGE_BAR_HOLD, type Hud } from '../src/game/hud' import { createSeats, isParticipant, seatOf } from '../src/game/roster' import { createDevHook, installDevHook, type DevHook } from '../src/core/dev-hook' @@ -1263,10 +1263,16 @@ function testDeathPlaysBeforeTheDebrief(): void { const budget = Math.ceil(20 / STEP) let frames = 0 + let lastScore = 0 + let scoreBeforeDeath = -1 for (; frames < budget && resultFrame < 0; frames++) { + if (deathFrame < 0) lastScore = game.snapshot()?.score ?? lastScore game.step([pilot.advance(input.state, STEP)]) - if (deathFrame < 0 && (game.snapshot()?.hull ?? 1) <= 0) deathFrame = frames + if (deathFrame < 0 && (game.snapshot()?.hull ?? 1) <= 0) { + deathFrame = frames + scoreBeforeDeath = lastScore + } if (game.dying) { dyingFrames++ // Escape mid-explosion must not strand the player in a paused fireball @@ -1285,6 +1291,9 @@ function testDeathPlaysBeforeTheDebrief(): void { check('the player died', deathFrame > 0, `hull never reached zero in ${(frames * STEP).toFixed(1)}s`) check('the run resolved', run !== null, `no result after ${(frames * STEP).toFixed(1)}s`) check('a fatal hit is recorded as a loss', run?.won === false, `won=${run?.won}`) + // The mine that killed it is the arena's, and in a match of one the arena's + // kills pay the sole seat — except this one: a seat is never paid for its own death. + check('and the seat was not paid for its own death', run !== null && run.score === scoreBeforeDeath, `${scoreBeforeDeath} before, ${run?.score} reported`) check( 'the debrief does not land on the frame of death', resultFrame > deathFrame, @@ -4569,7 +4578,7 @@ function testAMinePaysTheLastHitter(): void { const wave1 = waitForWave() mine(3) - const untouched = { line: line(), feed: game.capture().feed.map((e) => e.killer), airborne: remaining() } + const untouched = { line: line(), feed: game.capture().feed.map((e) => e.killer), awards: game.capture().feed.map((e) => e.award), airborne: remaining() } const wave2 = waitForWave() for (let i = 0; i < Math.ceil(10 / STEP); i++) { @@ -4608,6 +4617,34 @@ function testAMinePaysTheLastHitter(): void { `${one.untouched.line[0].kills} kills, ${one.untouched.line[0].hits} hits, score ${one.untouched.line[0].score}`) check('and so does the second, without a shot fired', one.worked.airborne === 1 && one.worked.line[0].kills === 5 && one.worked.line[0].shotsFired === 0, `${one.worked.line[0].kills} kills, ${one.worked.line[0].shotsFired} shots`) + // Points, not hits: the mine's damage on each of the three, plus the three bounties. + const awards = one.untouched.awards.reduce((a, b) => a + b, 0) + check("and the mine's damage was the sole seat's points too, not its hits", + one.untouched.line[0].score === awards + 3 * MINE_DAMAGE && one.untouched.line[0].hits === 0, + `score ${one.untouched.line[0].score}, bounties ${awards}, damage ${3 * MINE_DAMAGE}`) + + /* And the arena names itself. A hull driven into a station is damaged by + `FACTION_ENVIRONMENT`, whichever side the hull is on — not by "the other + side", which with a roster is a real seat that did nothing. */ + { + const station = { center: new THREE.Vector3(0, 0, -4000), radius: 300 } as unknown as Hazard + const bolts = createBolts() + const seen: Faction[] = [] + for (const faction of [FACTION_AI, FACTION_PLAYER, humanFaction(1)]) { + const ship = new Ship(SHIPS.hornet, faction) + ship.spawn(new THREE.Vector3(0, 0, -4000 + 300 + ship.radius - 2), new THREE.Vector3(0, 0, -4000)) + ship.velocity.set(0, 0, -400) + ship.onDamaged = (_self, _amount, from) => { + seen.push(from) + } + const ctx: ShipContext = { hazards: [station], audio: silentAudio(), bolts, localFaction: FACTION_PLAYER } + ship.step(controls(), STEP, ctx) + ship.dispose() + } + bolts.dispose() + check('a station scrape is the arena\'s, whichever side the hull is on', + seen.length === 3 && seen.every((f) => f === FACTION_ENVIRONMENT), JSON.stringify(seen)) + } SHIPS.wasp.maxHull = original.waspHull SHIPS.drone.maxHull = original.droneHull @@ -4619,6 +4656,181 @@ function testAMinePaysTheLastHitter(): void { SHIPS.hornet.damage = original.hornetDamage } +/** + * How a match ends, for everybody: the scoreboard's rules, and the wire that + * carries it. + * + * `placeLines` is the rule: by score, equal scores share a place, eliminated + * seats place after every flying one whatever they scored, and the winners are + * the seats placed first on a cleared squadron — none when the arena emptied. + * Then the whole thing over the loopback: a two-seat match the host resolves by + * clearing the squadron, with the host's own hull dented first so the placing is + * not a tie. Both machines are told the match ended, with the same lines; the + * mirror is told through a RESULT frame, once, and hears no snapshots after it. + */ +function testTheMatchEndsOnEveryMachine(): void { + section('The match ends on every machine, with one scoreboard') + + const line = (seat: number, score: number, alive = true): SeatLine => ({ + seat, ship: 'hornet', score, kills: 0, deaths: 0, hits: 0, shots: 0, alive, place: 0, won: false, + }) + + const tie = [line(0, 500), line(1, 900), line(2, 900)] + placeLines(tie) + check('equal scores share a place, and the next place is skipped', tie.map((l) => l.place).join(',') === '3,1,1', tie.map((l) => l.place).join(',')) + check('and every seat placed first on a cleared squadron has won', tie.map((l) => l.won).join(',') === 'false,true,true') + + const out = [line(0, 5000, false), line(1, 100), line(2, 100, false)] + placeLines(out) + check('an eliminated seat places after every flying one whatever it scored', out.map((l) => l.place).join(',') === '2,1,3', out.map((l) => l.place).join(',')) + check('and does not win', out.map((l) => l.won).join(',') === 'false,true,false') + + const empty = [line(0, 800, false), line(1, 200, false)] + placeLines(empty) + check('a match that emptied the arena has a placing and no winner', empty.map((l) => l.place).join(',') === '1,2' && empty.every((l) => !l.won)) + + /* The frame. */ + const result: MatchResult = { + time: Math.fround(83.25), + cleared: true, + lines: [ + { seat: 0, ship: 'wasp', score: 4321, kills: 5, deaths: 1, hits: 40, shots: 120, alive: true, place: 2, won: false }, + { seat: 1, ship: 'drone', score: 5000, kills: 6, deaths: 0, hits: 30, shots: 31, alive: true, place: 1, won: true }, + ], + } + const frame = encodeResult(result) + let back: MatchResult | null = null + try { + back = decodeResult(frame) + } catch { + back = null + } + check('a result frame decodes to the result that was encoded', back !== null && JSON.stringify(back) === JSON.stringify(result), + JSON.stringify(back)) + let refused = 0 + for (const bad of [frame.subarray(0, frame.length - 1), new Uint8Array([...frame, 0]), new Uint8Array([FRAME.RESULT, 99]), new Uint8Array([FRAME.WELCOME, 1])]) { + try { + decodeResult(bad) + } catch (e) { + if (e instanceof RangeError) refused++ + } + } + check('a short, long, foreign-version or wrong-type result frame is refused', refused === 4, `${refused} of 4`) + + /* Over the wire. */ + const original = { + waspHull: SHIPS.wasp.maxHull, + droneHull: SHIPS.drone.maxHull, + waspDamage: SHIPS.wasp.damage, + droneDamage: SHIPS.drone.damage, + waspRadius: SHIPS.wasp.radius, + droneRadius: SHIPS.drone.radius, + } + SHIPS.wasp.maxHull = 30 + SHIPS.drone.maxHull = 30 + SHIPS.wasp.damage = 0 + SHIPS.drone.damage = 0 + SHIPS.wasp.radius = 350 + SHIPS.drone.radius = 350 + const SENTINEL = 350 + const hornetRadius = SHIPS.hornet.radius + + /** + * A two-seat match the host resolves by clearing the squadron, its own hull + * dented first so the placing is not a tie, over a wire that loses `loss` of + * everything; then `after` more ticks for the result to cross. + */ + function fly(loss: number, after: number) { + const field = aimedMinefield() + let hostEnd: RunResult | null = null + let clientEnd: RunResult | null = null + const hostGame = newMatch({ environment: { ...stubEnvironment(), minefield: field }, onEnd: (r) => (hostEnd = r) }) + const clientGame = newMatch({ onEnd: (r) => (clientEnd = r) }) + const host = createHost({ game: hostGame, setup: { ships: ['hornet', 'hornet'], seed: 0xe4d, respawn: true } }) + host.start() + const wire = createLoopback({ loss, seed: 5 }) + const client = createClient({ game: clientGame, channel: wire.b }) + host.accept(wire.a) + const idle = controls({ throttle: 0.3 }) + const step = () => { + client.tick(idle) + host.tick(idle) + wire.pump() + } + for (let i = 0; i < 600 && client.seat < 0; i++) step() + // Everybody in, then dent the host's own hull with one mine (roster order picks seat 0). + for (let i = 0; i < Math.ceil(40 / STEP); i++) { + step() + if ((hostGame.snapshot(0)?.enemiesQueued ?? 1) === 0) break + } + field.aim((r) => r === hornetRadius) + field.arm() + step() + field.aim(() => false) + const dented = hostGame.snapshot(0)!.hull + // Then the mine clears the squadron, and the match resolves on the host. + field.aim((r) => r === SENTINEL) + let resolvedAt = -1 + let lastHull = dented + for (let i = 0; i < 600 && resolvedAt < 0; i++) { + lastHull = hostGame.snapshot(0)?.hull ?? lastHull + field.arm() + step() + if (!hostGame.active) resolvedAt = i + } + const appliedAtEnd = client.stats.applied + const malformedAtEnd = client.stats.malformed + for (let i = 0; i < after; i++) step() + const out = { + h: hostEnd as RunResult | null, + c: clientEnd as RunResult | null, + dented, + lastHull, + resolvedAt, + results: host.stats.results, + concluded: !clientGame.active && clientGame.result !== null, + quiet: client.stats.applied === appliedAtEnd && client.stats.malformed === malformedAtEnd && client.waiting === 0, + stats: { ...client.stats }, + lost: wire.lost, + } + hostGame.dispose() + clientGame.dispose() + return out + } + + const clean = fly(0, 10) + const lossy = fly(0.5, 240) + SHIPS.wasp.maxHull = original.waspHull + SHIPS.drone.maxHull = original.droneHull + SHIPS.wasp.damage = original.waspDamage + SHIPS.drone.damage = original.droneDamage + SHIPS.wasp.radius = original.waspRadius + SHIPS.drone.radius = original.droneRadius + + const { h, c, dented, lastHull, resolvedAt } = clean + check('the host dented its own hull and then cleared the squadron', dented < SHIPS.hornet.maxHull && resolvedAt >= 0, `hull ${dented}, resolved at ${resolvedAt}`) + check('the host reported the match ended, with a scoreboard', h !== null && h.match !== undefined && h.match.cleared && h.match.lines.length === 2, JSON.stringify(h)) + check('and told its peer once so far', clean.results === 1, `${clean.results}`) + check('the mirror reported the match ended too, and is no longer running', c !== null && clean.concluded, JSON.stringify(c)) + // The lines exactly; the clock to the float32 the wire carries it at. + check('with the same scoreboard', h !== null && c !== null && h.match !== undefined && c.match !== undefined && + JSON.stringify(h.match.lines) === JSON.stringify(c.match.lines) && Math.abs(h.match.time - c.match.time) < 1e-3 && h.match.cleared === c.match.cleared, + `${JSON.stringify(h?.match)} vs ${JSON.stringify(c?.match)}`) + check('the dented host placed second and did not win; the peer placed first and did', + h !== null && c !== null && !h.won && c.won && h.match?.lines[0].place === 2 && h.match?.lines[1].place === 1 && h.score < c.score, + `host ${h?.score} won=${h?.won}, client ${c?.score} won=${c?.won}`) + check('and each machine reported its own seat', h?.ship === 'hornet' && c?.ship === 'hornet' && h?.score === h?.match?.lines[0].score && c?.score === c?.match?.lines[1].score) + check('the host paid both for finishing, the intact one more by the hull it was missing', h !== null && h.match !== undefined && + h.match.lines[1].score - h.match.lines[0].score === 1200 - Math.round((lastHull / SHIPS.hornet.maxHull) * 1200), + `${h?.match?.lines.map((l) => l.score).join(' vs ')}, hull ${lastHull}`) + check('no snapshot followed the result', clean.quiet, JSON.stringify(clean.stats)) + + check('over a wire that loses half of everything, the match still resolved', lossy.resolvedAt >= 0 && lossy.lost > 100, `resolved at ${lossy.resolvedAt}, ${lossy.lost} lost`) + check('and the result was said again until it got through, so the mirror concluded too', lossy.results >= 2 && lossy.concluded && lossy.c !== null, + `${lossy.results} result frames, concluded=${lossy.concluded}`) + check('with the same scoreboard as the host', lossy.h !== null && lossy.c !== null && JSON.stringify(lossy.h.match?.lines) === JSON.stringify(lossy.c.match?.lines)) +} + /** * A HUD stub that records what the squadron looks like, not just where it is. * @@ -5222,6 +5434,15 @@ function testAnEliminatedSeatDoesNotInheritTheWin(): void { asVictim.scoreAtEnd === asVictim.scoreAtDeath && asVictim.scoreAtDeath === 0, `${asVictim.scoreAtDeath} at death, ${asVictim.scoreAtEnd} when the match ended`, ) + /* The scoreboard both machines get says the same: the eliminated seat is placed + last, paid no finishing bonus, and did not win; the survivor did. */ + const board = asVictim.result?.match + check( + 'on the scoreboard the eliminated seat is last, unpaid for finishing, and did not win', + board !== undefined && board.lines[0].alive === false && board.lines[0].place === 2 && !board.lines[0].won && + board.lines[0].score === asVictim.scoreAtDeath && board.lines[1].alive && board.lines[1].place === 1 && board.lines[1].won, + JSON.stringify(board), + ) /* The other viewpoint, from the same match: the survivor really did win, so the check above is about *whose* result is reported rather than about the match's outcome. */ check( @@ -6169,8 +6390,8 @@ function testDeathEitherRespawnsOrResolves(): void { * * What this deliberately does not claim is that watching a teammate fly on after * your own hull is gone is *good*. It is the honest generalisation of the rule - * that exists, and what a match should actually do with an eliminated participant - * is milestone 8's. + * that exists; the scoreboard the match ends with places an eliminated seat after + * every flying one (`placeLines`), which is what it says about them. */ function testEliminationEndsWhenTheArenaEmpties(): void { section('Elimination ends the run when the arena empties') @@ -6965,6 +7186,7 @@ testScoringIsPerSeat() testTwoScorersKeepSeparateStreaks() testShootingAParticipantPays() testAMinePaysTheLastHitter() +testTheMatchEndsOnEveryMachine() testTheStepClockNeverLosesTime() testARunMatchesItsRecordedBaseline() testOneFrameDepictsOneInstant() diff --git a/src/core/scores.ts b/src/core/scores.ts index c3831d0..27330d1 100644 --- a/src/core/scores.ts +++ b/src/core/scores.ts @@ -18,6 +18,44 @@ export interface RunResult { time: number won: boolean accuracy: number + /** + * The whole match this run was one seat of, when there was more than one + * seat to tell about. Absent for the single-player game, whose result is + * this struct alone. + */ + match?: MatchResult +} + +/** One seat's line on the final scoreboard. */ +export interface SeatLine { + seat: number + ship: ShipId + /** The scoreline plus whatever bonus the match paid at the end. */ + score: number + kills: number + deaths: number + hits: number + shots: number + /** Still flying when the match ended. */ + alive: boolean + /** 1 is first; equal scores share a place. */ + place: number + won: boolean +} + +/** + * How a match ended, for every seat. + * + * Computed once, on the host, when the match resolves, and sent to every + * mirror, so the same scoreboard is shown on every machine. `cleared` is the + * squadron being gone; a match that ended with every seat eliminated has it + * false and nobody `won`. + */ +export interface MatchResult { + /** Seconds elapsed. */ + time: number + cleared: boolean + lines: SeatLine[] } interface Best { diff --git a/src/game/game.ts b/src/game/game.ts index 0286d2c..2101730 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -25,7 +25,7 @@ import * as THREE from 'three' import type { Audio } from '../core/audio' import type { Input } from '../core/input' import { STREAM, subRng, type Rng } from '../core/rng' -import type { RunResult } from '../core/scores' +import type { MatchResult, RunResult, SeatLine } from '../core/scores' import { otherShips, SHIPS, type ShipId } from '../ships/specs' import { ARENA_RADIUS, @@ -91,6 +91,25 @@ export const STEP = 1 / 60 */ export const PARTICIPANT_BOUNTY_MULT = 2 +/** + * Place every line by score, equal scores sharing a place, and mark the winners: + * the seats placed first and still flying. Eliminated seats place after every + * flying one whatever their score — a seat that is not in the arena when it is + * cleared did not clear it. A match that emptied the arena has nobody flying, + * so nobody wins it; "cleared" is implied by "alive at the end". + */ +export function placeLines(lines: SeatLine[]): void { + const order = lines.slice().sort((a, b) => (a.alive === b.alive ? b.score - a.score : a.alive ? -1 : 1)) + let place = 0 + for (let i = 0; i < order.length; i++) { + const line = order[i] + const prev = order[i - 1] + if (!prev || prev.alive !== line.alive || prev.score !== line.score) place = i + 1 + line.place = place + line.won = line.alive && place === 1 + } +} + /** Hulls of each non-chosen type that make up the squadron. */ const PER_ENEMY_TYPE = 3 /** How many enemies are airborne at once. */ @@ -322,6 +341,14 @@ export interface Game { * is not a stall. */ coast(except: number): void + /** + * Mirror only: the host's match has resolved and this is how. Ends the + * match here the way `finish` does on the host — arena cleared, `onEnd` + * called with this machine's seat's line as its `RunResult`. + */ + conclude(result: MatchResult): void + /** How the last match ended, once it has; `null` while one is running or before any. */ + readonly result: MatchResult | null dispose(): void } @@ -539,6 +566,8 @@ export function createGame(deps: GameDeps): Game { * a resolution, so there is nothing to seal. */ let pendingResult: RunResult | null = null + /** How the last match ended. Set by `finish`, read by the host to tell its peers. */ + let lastResult: MatchResult | null = null const contactBuffer: HudContact[] = [] /** Enemy-only view of the arena, reused each frame for AI separation. */ @@ -1063,48 +1092,66 @@ export function createGame(deps: GameDeps): Game { } /** - * The scoreline at the instant the run resolves, for the seat being drawn. - * - * Sealed here rather than read at `finish`, because a loss keeps the arena - * running for `DEATH_SEQUENCE` seconds afterwards — long enough for a hostile - * to fly into the star and post a bounty to a pilot who is already dead. - * - * Reports the local seat because `RunResult` is what the debrief and the score - * store consume, and both are single-player shaped: one ship, one score, one - * accuracy. A match-wide result — every seat's line, a winner, a placing — is - * milestone 8's, and inventing the shape now would mean guessing at the rules - * it has to describe. + * The match's end, for every seat: the rules of the scoreboard, in one place. * - * **Reads state and writes none**, which it did not used to do: the win bonuses - * were added to the running score. That was invisible with one seat and is a - * leak with more than one — the seat being *drawn* would end the match with a - * different score from the seat beside it, so which machine was watching would - * change a number the simulation owns. Who deserves a win bonus in a match with - * several seats is a match rule, and until milestone 8 decides one, the bonus - * belongs to the report rather than to the scoreline. + * On a cleared squadron every seat still flying is paid for finishing intact + * (hull fraction × 1200) and for finishing fast (4000 − 25/s, floored at zero), + * which for one seat is exactly the win bonus the debrief always showed. Seats + * that were eliminated are paid nothing more. Every seat is then placed by its + * final score, equal scores sharing a place, and the seats placed first on a + * cleared squadron have won. A match that ended with everybody eliminated has + * no winner. The time bonus is the same for everyone and cannot change the + * placing; the hull bonus can, and is meant to: finishing intact is part of + * the game. + */ + function sealMatch(cleared: boolean): MatchResult { + const timeBonus = cleared ? Math.max(0, Math.round(4000 - elapsed * 25)) : 0 + const lines: SeatLine[] = seats.map((seat) => { + const alive = seat.phase.kind === 'flying' && seat.ship.alive + const bonus = cleared && alive ? Math.round(seat.ship.hullFraction * 1200) + timeBonus : 0 + return { + seat: seat.index, + ship: seat.ship.spec.id, + score: seat.score + bonus, + kills: seat.kills, + deaths: seat.deaths, + hits: seat.hits, + shots: seat.ship.shotsFired, + alive, + place: 0, + won: false, + } + }) + placeLines(lines) + return { time: elapsed, cleared, lines } + } + + /** One seat's line as the report its own debrief and score store consume. */ + function runResultOf(line: SeatLine, match: MatchResult): RunResult { + return { + ship: line.ship, + score: line.score, + kills: line.kills, + time: match.time, + won: line.won, + accuracy: line.shots > 0 ? Math.min(1, line.hits / line.shots) : 0, + match: match.lines.length > 1 ? match : undefined, + } + } + + /** + * The scoreline at the instant the local seat is eliminated, sealed. * - * **No test covers this, and it is not for want of trying.** Restoring the - * mutation — `seat.score += bonus` — leaves all 292 checks green, because with - * today's call sites the write is unobservable: `sealResult(true)` is reached - * from exactly one place, `finish(sealResult(true))`, and `finish` calls - * `clearArena` before returning, so no caller can read a seat's score between - * the two. On a loss the bonus is zero and the write is a no-op. The purity is - * therefore defensive rather than currently load-bearing — it becomes real at - * milestone 8, where a win stops ending the match. Written down because a - * mutation that survives is worth a sentence, not a pretend assertion. + * A loss keeps the arena running for `DEATH_SEQUENCE` seconds afterwards — + * long enough for a hostile this seat had hit to fly into a mine and pay a + * pilot who is already dead — and the report is what the seat died with. */ function sealResult(won: boolean): RunResult { const seat = local() if (!seat) return { ship: 'hornet', score: 0, kills: 0, time: elapsed, won, accuracy: 0 } - - // Reward finishing intact and finishing fast, in that order. - const bonus = won - ? Math.round(seat.ship.hullFraction * 1200) + Math.max(0, Math.round(4000 - elapsed * 25)) - : 0 - return { ship: seat.ship.spec.id, - score: seat.score + bonus, + score: seat.score, kills: seat.kills, time: elapsed, won, @@ -1112,6 +1159,24 @@ export function createGame(deps: GameDeps): Game { } } + /** + * Resolve the match: seal every seat's line, report the local seat's, and + * keep the whole for the host to send. A local seat that was eliminated + * reports the line it was sealed with at death, attached to the match. + */ + function resolveMatch(cleared: boolean): void { + const match = sealMatch(cleared) + const mine = local() + const line = mine ? match.lines[mine.index] : undefined + const report = pendingResult + ? { ...pendingResult, match: match.lines.length > 1 ? match : undefined } + : line + ? runResultOf(line, match) + : sealResult(false) + lastResult = match + finish(report) + } + function finish(result: RunResult): void { if (!active) return active = false @@ -1458,6 +1523,16 @@ export function createGame(deps: GameDeps): Game { if (seat >= 0 && seat < acks.length) acks[seat] = tick } + /** The mirror's `resolveMatch`: the host decided, this machine reports its seat's line. */ + function conclude(result: MatchResult): void { + if (!active) return + const mine = local() + const line = mine ? result.lines.find((l) => l.seat === mine.index) : undefined + lastResult = result + if (line?.won) hud.callout('SECTOR CLEAR', '#b6ff3d', 3) + finish(line ? runResultOf(line, result) : sealResult(false)) + } + /** * The mirror's tick when nothing arrived. * @@ -1784,7 +1859,7 @@ export function createGame(deps: GameDeps): Game { // first wreck to resolve used to call `finish` — and `clearArena` with it — // straight through a second wreck that was 85 ticks into its own 144. The // match waits for every cutscene it started. - if (!matchStillRunning()) finish(pendingResult ?? sealResult(false)) + if (!matchStillRunning()) resolveMatch(false) } /* ------------------------------------------------------------------------ */ @@ -1955,9 +2030,9 @@ export function createGame(deps: GameDeps): Game { if (!active) return } - /* A cleared squadron is still the win, and still the only one. What it means - with more than one seat in the arena — shared, first past a post, highest - score — is a match rule, and match rules are milestone 8. + /* A cleared squadron is still the win, and still the only one. With more than + one seat in the arena it is a *placing*: `sealMatch` pays every seat still + flying for finishing, ranks them, and the seats placed first have won. Gated on nobody being mid-cutscene as well as somebody being alive, and both halves are load-bearing. The squadron can empty on the very tick a seat dies @@ -1981,7 +2056,7 @@ export function createGame(deps: GameDeps): Game { * only way to reach this branch is to still be alive, and `pendingResult` is null. */ if (!pendingResult) hud.callout('SECTOR CLEAR', '#b6ff3d', 3) - finish(pendingResult ?? sealResult(true)) + resolveMatch(true) } } @@ -2283,6 +2358,7 @@ export function createGame(deps: GameDeps): Game { overdriveWarned = false shieldWarned = false pendingResult = null + lastResult = null best = deps.bestScoreFor(localSpec.id) environment.minefield.reset() @@ -2395,6 +2471,10 @@ export function createGame(deps: GameDeps): Game { reconcile, acknowledge, coast, + conclude, + get result() { + return lastResult + }, cycleTarget() { const seat = local() diff --git a/src/net/session.ts b/src/net/session.ts index 8393d10..3e30e99 100644 --- a/src/net/session.ts +++ b/src/net/session.ts @@ -12,6 +12,7 @@ * INTENT client -> host an intent frame (`wire.ts`), tick-stamped * SNAPSHOT host -> client the world (`snapshot.ts`), tick-stamped inside * REFUSED host -> client no seat for you + * RESULT host -> client the match has resolved: every seat's line * * Three rules carry the anti-cheat weight, and the tests name each: * @@ -29,6 +30,7 @@ * this layer never constructs a `Controls` by hand. */ +import type { MatchResult, SeatLine } from '../core/scores' import { admitIntent } from '../game/intent' import { STEP, type Game, type MatchSetup } from '../game/game' import type { Controls } from '../game/ship' @@ -48,6 +50,7 @@ export const FRAME = { INTENT: 3, SNAPSHOT: 4, REFUSED: 5, + RESULT: 6, } as const /* ---- Frames --------------------------------------------------------------- */ @@ -98,6 +101,45 @@ export function decodeWelcome(bytes: Uint8Array): Welcome { return { seat, setup: { ships, seed, respawn, local: seat } } } +export function encodeResult(result: MatchResult): Uint8Array { + const w = new ByteWriter(64) + w.u8(FRAME.RESULT).u8(PROTOCOL_VERSION).f32(result.time).bool(result.cleared).u8(result.lines.length) + for (const l of result.lines) { + w.u8(l.seat).u8(SHIP_ORDER.indexOf(l.ship)).i32(l.score).u16(l.kills).u16(l.deaths).u32(l.hits).u32(l.shots) + w.bool(l.alive).u8(l.place).bool(l.won) + } + return w.bytes() +} + +export function decodeResult(bytes: Uint8Array): MatchResult { + const r = new ByteReader(bytes) + const type = r.u8() + if (type !== FRAME.RESULT) throw new RangeError(`not a result frame: ${type}`) + const version = r.u8() + if (version !== PROTOCOL_VERSION) throw new RangeError(`protocol ${version}, expected ${PROTOCOL_VERSION}`) + const time = r.f32() + const cleared = r.bool() + const count = r.u8() + const lines: SeatLine[] = [] + for (let i = 0; i < count; i++) { + const seat = r.u8() + const ship = SHIP_ORDER[r.u8()] + if (!ship) throw new RangeError('unknown hull in result') + const score = r.i32() + const kills = r.u16() + const deaths = r.u16() + const hits = r.u32() + const shots = r.u32() + const alive = r.bool() + const place = r.u8() + const won = r.bool() + if (place < 1 || place > count) throw new RangeError(`place ${place} of ${count}`) + lines.push({ seat, ship, score, kills, deaths, hits, shots, alive, place, won }) + } + r.finish() + return { time, cleared, lines } +} + /* ---- Host ----------------------------------------------------------------- */ export interface HostStats { @@ -113,6 +155,8 @@ export interface HostStats { admitted: number /** Peers refused for want of a seat. */ refused: number + /** Result frames sent: one per peer when the match resolved, and every half second after. */ + results: number } export interface HostOptions { @@ -169,9 +213,12 @@ export function createHost(options: HostOptions): Host { const intents: Controls[] = Array.from({ length: seatCount }, () => neutral()) const holds: Controls[] = Array.from({ length: seatCount }, () => neutral()) const scratch: Controls = neutral() - const stats: HostStats = { wrongSeat: 0, stale: 0, malformed: 0, held: 0, admitted: 0, refused: 0 } + const stats: HostStats = { wrongSeat: 0, stale: 0, malformed: 0, held: 0, admitted: 0, refused: 0, results: 0 } let tick = 0 let sinceSnapshot = 0 + /** The result the peers are being told, and how long since it was last said. */ + let resultSent: MatchResult | null = null + let sinceResult = 0 function onFrame(peer: Peer, bytes: Uint8Array): void { if (bytes.length === 0) { @@ -215,6 +262,7 @@ export function createHost(options: HostOptions): Host { game.start(setup) tick = 0 sinceSnapshot = 0 + resultSent = null }, accept(channel) { @@ -236,6 +284,30 @@ export function createHost(options: HostOptions): Host { }, tick(local) { + // A resolved match is told to every peer, and nothing else is sent: the + // roster is gone, and a snapshot of nobody is not a world. Said again + // every half second for as long as the host keeps ticking, because the + // wire drops frames and a result that never arrived is a debrief the + // joiner never sees; a mirror that has already concluded ignores repeats. + if (!game.active) { + const result = game.result + if (!result) return + if (result !== resultSent) { + resultSent = result + sinceResult = HELLO_EVERY + } + if (++sinceResult >= HELLO_EVERY) { + sinceResult = 0 + const bytes = encodeResult(result) + for (const peer of peers) { + if (peer && peer.channel.open) { + peer.channel.send(bytes) + stats.results++ + } + } + } + return + } intents[0] = local for (let seat = 1; seat < seatCount; seat++) { const peer = peers[seat] @@ -258,7 +330,9 @@ export function createHost(options: HostOptions): Host { game.step(intents) tick++ - if (++sinceSnapshot >= snapshotEvery) { + // Not on the tick the match resolved: `finish` has cleared the roster, + // and the result frame is what says so. + if (++sinceSnapshot >= snapshotEvery && game.active) { sinceSnapshot = 0 const bytes = withType(FRAME.SNAPSHOT, encodeSnapshot(game.capture())) for (const peer of peers) if (peer && peer.channel.open) peer.channel.send(bytes) @@ -470,6 +544,20 @@ export function createClient(options: ClientOptions): Client { options.onRefused?.() return } + if (type === FRAME.RESULT) { + if (seat < 0) return + let result: MatchResult + try { + result = decodeResult(bytes) + } catch { + stats.malformed++ + return + } + // Whatever is still queued is a world that has ended; the result is the last word. + queue.length = 0 + game.conclude(result) + return + } if (type === FRAME.SNAPSHOT) { if (seat < 0) return try { diff --git a/src/style.css b/src/style.css index ef974d5..8fa1b78 100644 --- a/src/style.css +++ b/src/style.css @@ -1096,6 +1096,48 @@ body { animation: warn-breathe 1.3s ease-in-out infinite; } +/* The match scoreboard: one row per seat, placed, the local seat lit. */ +.panel .placing { + font-size: 12px; + letter-spacing: 0.24em; + text-transform: uppercase; + color: rgba(223, 246, 255, 0.62); +} + +.panel .board { + display: grid; + gap: 4px; + width: 100%; + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(223, 246, 255, 0.62); +} + +.panel .board .row { + display: grid; + grid-template-columns: 2.6em 3.2em 1fr auto 1fr auto; + gap: 0 12px; + align-items: baseline; + padding: 4px 8px; + border: 1px solid rgba(107, 230, 255, 0.12); +} + +.panel .board .row b { + color: #dff6ff; + font-weight: 400; +} + +.panel .board .row.you { + border-color: rgba(107, 230, 255, 0.55); + color: #dff6ff; + text-shadow: 0 0 10px rgba(53, 245, 255, 0.5); +} + +.panel .board .out { + color: var(--magenta, #ff3b4e); +} + .panel .actions { display: flex; gap: 12px; diff --git a/src/ui/panels.ts b/src/ui/panels.ts index 4489d66..51b5df6 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -148,7 +148,11 @@ export function createDebriefPanel(deps: DebriefDeps): DebriefPanel { const panel = el('div', 'panel') const heading = el('h2') + const placing = el('div', 'placing') + placing.hidden = true const scoreBig = el('div', 'score-big', '0') + const board = el('div', 'board') + board.hidden = true const lines = el('div', 'lines') const record = el('div', 'record', 'New personal best') record.hidden = true @@ -158,7 +162,7 @@ export function createDebriefPanel(deps: DebriefDeps): DebriefPanel { const hangar = button('Change ship', false) actions.append(replay, hangar) - panel.append(heading, scoreBig, record, lines, actions) + panel.append(heading, placing, scoreBig, record, board, lines, actions) root.append(panel) deps.parent.append(root) @@ -168,10 +172,35 @@ export function createDebriefPanel(deps: DebriefDeps): DebriefPanel { return { show(result, isRecord, best) { const spec = SHIPS[result.ship] - heading.textContent = result.won ? 'Sector clear' : 'Hull breach' + const match = result.match + const mine = match?.lines.find((l) => l.ship === result.ship && l.won === result.won && l.score === result.score) + // Three ways for a match to end for you: you cleared it, you were shot + // down, or somebody cleared it and outscored you. + const outscored = !result.won && match !== undefined && match.cleared && mine?.alive === true + heading.textContent = result.won ? 'Sector clear' : outscored ? 'Outscored' : 'Hull breach' heading.className = result.won ? 'glow-cyan' : 'glow-magenta' scoreBig.textContent = result.score.toLocaleString() + // The scoreboard, when there was more than one seat to place. + board.innerHTML = '' + board.hidden = !match || match.lines.length < 2 + placing.hidden = board.hidden + if (match && match.lines.length > 1) { + const ordinal = (n: number) => `${n}${['th', 'st', 'nd', 'rd'][n % 10 > 3 || Math.floor((n % 100) / 10) === 1 ? 0 : n % 10]}` + placing.textContent = mine ? `${ordinal(mine.place)} of ${match.lines.length}` : '' + const ordered = match.lines.slice().sort((a, b) => a.place - b.place || a.seat - b.seat) + for (const line of ordered) { + const row = el('div', line === mine ? 'row you' : 'row') + const who = line === mine ? 'YOU' : `P${line.seat + 1}` + const acc = line.shots > 0 ? Math.round(Math.min(1, line.hits / line.shots) * 100) : 0 + row.innerHTML = + `${ordinal(line.place)}${who}${SHIPS[line.ship].name}` + + `${line.score.toLocaleString()}${line.kills} kills · ${line.deaths} deaths · ${acc}%` + + (line.alive ? '' : 'eliminated') + board.append(row) + } + } + lines.innerHTML = '' const rows: [string, string][] = [ ['Airframe', spec.name], From 9c6da34632d2755cd7a544298049942d9326e585 Mon Sep 17 00:00:00 2001 From: Stephen DeLorme Date: Fri, 4 Sep 2026 15:32:03 -0400 Subject: [PATCH 3/3] Point five mutants at the code milestone 8 moved, and pin the clock a sealed loss reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing onto the merged #27 and running the gate on the whole branch found five mutations that no longer applied — the gate's "not testable", which it rightly fails over, and which the earlier per-commit runs had not seen because they predate the code moving: - "the host never tells its peers the match ended" targeted the first draft of the RESULT send, before it was made to repeat. Now it silences the resend branch itself. A sixth mutation joins it: a result said once and never again, which the 50%-loss wire check already pins. - "every hit is credited to seat 0" and "every kill is credited to seat 0" targeted the pre-M8 fallback-to-seat-0 code. Now they hit `onDamaged`'s direct credit and `bountyGoesTo`. - "finish waits only for flying seats" and "a teammate's win overwrites the drawn seat's sealed loss" targeted `finish(pendingResult ?? …)`, which became `resolveMatch`. The first now weakens the guard on `resolveMatch(false)`; the second drops the sealed-result branch. That last one was equivalent on everything the eliminated-seat check read: the board's line for an eliminated seat already says it lost, with the score it died with. What differs is the clock — a result sealed at death carries the time it died; the line read at resolution carries the time the match ended, seconds later. The check now pins that too: reported time within a tick of the death, and well before the resolution. Simulation checks 607 -> 608; 109 -> 110 mutations. Co-authored-by: Claude Fable 5.1 Signed-off-by: Stephen DeLorme --- scripts/mutate.mjs | 24 +++++++++++++++--------- scripts/simcheck.ts | 20 +++++++++++++++++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/scripts/mutate.mjs b/scripts/mutate.mjs index 19797fb..14e31e0 100644 --- a/scripts/mutate.mjs +++ b/scripts/mutate.mjs @@ -377,9 +377,15 @@ const MUTATIONS = [ { name: 'the host never tells its peers the match ended', file: 'src/net/session.ts', - from: ' if (result && result !== resultSent) {', + from: ' if (++sinceResult >= HELLO_EVERY) {', to: ' if (false) {', }, + { + name: 'the result is said once and never again', + file: 'src/net/session.ts', + from: ' sinceResult = 0\n const bytes = encodeResult(result)', + to: ' sinceResult = Number.NEGATIVE_INFINITY\n const bytes = encodeResult(result)', + }, { name: 'the host snapshots a roster of nobody on the resolving tick', file: 'src/net/session.ts', @@ -459,13 +465,13 @@ const MUTATIONS = [ { name: 'every hit is credited to seat 0', file: 'src/game/game.ts', - from: ' const scorer = seatOf(seats, from)\n if (!scorer) return\n creditHit(scorer, amount)', - to: ' const scorer = seats[0]\n if (!scorer) return\n creditHit(scorer, amount)', + from: ' const direct = seatOf(seats, from)\n if (direct) {', + to: ' const direct = seats[0]\n if (direct) {', }, { name: 'every kill is credited to seat 0', file: 'src/game/game.ts', - from: ' return seatOf(seats, from) ?? seats[0] ?? null', + from: ' return seatOf(seats, from) ?? lastHitter.get(victim) ?? soleSeat()', to: ' return seats[0] ?? null', }, { @@ -499,8 +505,8 @@ const MUTATIONS = [ { name: 'finish waits only for flying seats, not for wrecks', file: 'src/game/game.ts', - from: ' if (!matchStillRunning()) finish(pendingResult ?? sealResult(false))', - to: ' if (!anySeatFlying()) finish(pendingResult ?? sealResult(false))', + from: ' if (!matchStillRunning()) resolveMatch(false)', + to: ' if (!anySeatFlying()) resolveMatch(false)', }, { /* @@ -511,8 +517,8 @@ const MUTATIONS = [ */ name: "a teammate's win overwrites the drawn seat's sealed loss", file: 'src/game/game.ts', - from: ' finish(pendingResult ?? sealResult(true))', - to: ' finish(sealResult(true))', + from: ' const report = pendingResult\n ? { ...pendingResult, match: match.lines.length > 1 ? match : undefined }\n : line', + to: ' const report = line', }, { name: 'a win is reported over a wreck', @@ -795,7 +801,7 @@ function runSuite() { * If you added or removed checks on purpose, bump this in the same commit. If you did not, * something stopped running. */ -const EXPECTED_ASSERTIONS = 607 +const EXPECTED_ASSERTIONS = 608 const PASS_SUMMARY = 'All checks passed.' const SUMMARY = /check\(s\) failed\.$|All checks passed\.$/ diff --git a/scripts/simcheck.ts b/scripts/simcheck.ts index 233395f..5a53a4e 100644 --- a/scripts/simcheck.ts +++ b/scripts/simcheck.ts @@ -5335,6 +5335,8 @@ function testAnEliminatedSeatDoesNotInheritTheWin(): void { wrecked: boolean scoreAtDeath: number scoreAtEnd: number + timeAtDeath: number + timeAtEnd: number } { const field = aimedMinefield() let ended: RunResult | null = null @@ -5362,6 +5364,7 @@ function testAnEliminatedSeatDoesNotInheritTheWin(): void { let wrecked = false let deaths = 0 let scoreAtDeath = -1 + let timeAtDeath = -1 for (let i = 0; i < 60; i++) { const view = game.snapshot(0) if (!view) break @@ -5370,6 +5373,7 @@ function testAnEliminatedSeatDoesNotInheritTheWin(): void { wrecked = true // Read on the first tick the wreck is visible, which is the tick after the seal. scoreAtDeath = view.score + timeAtDeath = view.elapsed break } game.step(hands) @@ -5377,17 +5381,19 @@ function testAnEliminatedSeatDoesNotInheritTheWin(): void { field.aim((r) => r === SENTINEL) let scoreAtEnd = scoreAtDeath + let timeAtEnd = timeAtDeath for (let i = 0; i < sequence + 240 && (ended as RunResult | null) === null; i++) { const view = game.snapshot(0) if (!view) break deaths = Math.max(deaths, view.deaths) scoreAtEnd = view.score + timeAtEnd = view.elapsed field.arm() game.step(hands) } game.dispose() - return { result: ended as RunResult | null, deaths, wrecked, scoreAtDeath, scoreAtEnd } + return { result: ended as RunResult | null, deaths, wrecked, scoreAtDeath, scoreAtEnd, timeAtDeath, timeAtEnd } } const asVictim = playFrom(0) @@ -5429,6 +5435,18 @@ function testAnEliminatedSeatDoesNotInheritTheWin(): void { asVictim.result?.score === asVictim.scoreAtDeath, `reported ${asVictim.result?.score}, had ${asVictim.scoreAtDeath} at death`, ) + /* + * And the clock it died on, not the clock the match ended on. This is what tells a + * result sealed at death from the same seat's line read off the board at resolution: + * the board also says it lost with that score, so score and verdict alone cannot. + */ + check( + 'and its run ended when it died, not when the match did', + asVictim.result !== null && + Math.abs(asVictim.result.time - asVictim.timeAtDeath) <= STEP && + asVictim.result.time < asVictim.timeAtEnd - DEATH_SEQUENCE / 2, + `reported ${asVictim.result?.time}s, died at ${asVictim.timeAtDeath}s, match ended at ${asVictim.timeAtEnd}s`, + ) check( 'and mines clearing the squadron after its death paid a seat that never fired nothing', asVictim.scoreAtEnd === asVictim.scoreAtDeath && asVictim.scoreAtDeath === 0,