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 541885f..14e31e0 100644 --- a/scripts/mutate.mjs +++ b/scripts/mutate.mjs @@ -287,6 +287,124 @@ 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)', + }, + + /* ---- 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 (++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', + 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', @@ -347,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', }, { @@ -387,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)', }, { /* @@ -399,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', @@ -683,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 = 567 +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 fc5d5a7..5a53a4e 100644 --- a/scripts/simcheck.ts +++ b/scripts/simcheck.ts @@ -14,23 +14,26 @@ 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' import { decodeSnapshot, encodeSnapshot, + FEED_RING, + NOBODY, SNAPSHOT_VERSION, + THE_ARENA, type ShipState, type WorldSnapshot, } from '../src/net/snapshot' 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, 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' @@ -1260,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 @@ -1282,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, @@ -2896,6 +2908,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 +3272,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 +3321,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 +3345,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 +3369,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 +3437,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 +4381,454 @@ 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. + * + * 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. * - * 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. + * 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. * - * 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. + * 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), awards: game.capture().feed.map((e) => e.award), 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`) + // 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 + 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 +} + +/** + * 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)) } /** @@ -4911,6 +5335,8 @@ function testAnEliminatedSeatDoesNotInheritTheWin(): void { wrecked: boolean scoreAtDeath: number scoreAtEnd: number + timeAtDeath: number + timeAtEnd: number } { const field = aimedMinefield() let ended: RunResult | null = null @@ -4938,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 @@ -4946,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) @@ -4953,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) @@ -4991,26 +5421,46 @@ 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', 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( - '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 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( @@ -5958,8 +6408,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') @@ -6752,7 +7202,9 @@ testTheDevHookReadsTheRunningGame() testTheLoopSurvivesTheEndOfARun() testScoringIsPerSeat() testTwoScorersKeepSeparateStreaks() -testShootingAParticipantScoresNothingYet() +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/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..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, @@ -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,35 @@ 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 + +/** + * 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. */ @@ -312,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 } @@ -492,6 +529,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() /** @@ -519,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. */ @@ -636,26 +685,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): Participant | null { - return seatOf(seats, from) ?? seats[0] ?? null + 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 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 +817,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 +896,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) } } @@ -1000,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, @@ -1049,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 @@ -1187,6 +1315,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) @@ -1394,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. * @@ -1537,6 +1676,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 +1804,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 @@ -1708,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) } /* ------------------------------------------------------------------------ */ @@ -1879,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 @@ -1905,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) } } @@ -2123,6 +2274,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 +2284,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. @@ -2189,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() @@ -2301,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/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/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/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. */ 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],