diff --git a/packages/client/src/arena/layoutFingerprint.ts b/packages/client/src/arena/layoutFingerprint.ts index 9526c126..a482b0ce 100644 --- a/packages/client/src/arena/layoutFingerprint.ts +++ b/packages/client/src/arena/layoutFingerprint.ts @@ -1,3 +1,4 @@ +import { fnv1a32 } from '@shobu/core' import type { GreyboxBlock } from './arenaBlock.ts' /** @@ -16,12 +17,7 @@ import type { GreyboxBlock } from './arenaBlock.ts' * ``` */ export function layoutFingerprint(blockout: readonly GreyboxBlock[]): string { - let hash = 0x811c9dc5 - for (const character of canonicalText(blockout)) { - hash ^= character.charCodeAt(0) - hash = Math.imul(hash, 0x01000193) >>> 0 - } - return hash.toString(16).padStart(8, '0') + return fnv1a32(canonicalText(blockout)).toString(16).padStart(8, '0') } /** diff --git a/packages/client/src/audio/loopSwitch.test.ts b/packages/client/src/audio/loopSwitch.test.ts new file mode 100644 index 00000000..4a9c2323 --- /dev/null +++ b/packages/client/src/audio/loopSwitch.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { createLoopTrack, type LoopPlayer, switchLoop } from './loopSwitch.ts' + +/** Uma fonte do motor falso: o som e a velocidade com que começou, e se parou. */ +interface FakeLoopSource { + readonly url: string + readonly rate: number + stopped: boolean +} + +/** + * O motor de áudio falso: o download só termina quando o teste manda, que é + * como a ordem de chegada dos downloads fica nas mãos do teste. + */ +class FakeLoopPlayer implements LoopPlayer { + readonly started: FakeLoopSource[] = [] + private readonly pending: Array<() => void> = [] + + bufferOf(url: string): Promise { + return new Promise((resolve) => this.pending.push(() => resolve(url))) + } + + start(buffer: string, rate: number): FakeLoopSource { + const source = { url: buffer, rate, stopped: false } + this.started.push(source) + return source + } + + stop(source: FakeLoopSource): void { + source.stopped = true + } + + /** Termina todo download pendente, na ordem do pedido. */ + async finishDownloads(): Promise { + for (const finish of this.pending.splice(0)) finish() + await Promise.resolve() + } + + playing(): FakeLoopSource[] { + return this.started.filter((source) => !source.stopped) + } +} + +describe('switchLoop', () => { + it('começa o laço pedido quando o download chega', async () => { + const player = new FakeLoopPlayer() + const track = createLoopTrack() + const switching = switchLoop(track, 'passo', 1.2, player) + await player.finishDownloads() + await switching + expect(player.playing()).toEqual([{ url: 'passo', rate: 1.2, stopped: false }]) + }) + + it('trocar para outro som para o laço que tocava', async () => { + const player = new FakeLoopPlayer() + const track = createLoopTrack() + const first = switchLoop(track, 'passo', 1, player) + await player.finishDownloads() + await first + const second = switchLoop(track, 'corrida', 1, player) + await player.finishDownloads() + await second + expect(player.playing().map((source) => source.url)).toEqual(['corrida']) + }) + + it('a troca pedida durante o download vence a anterior', async () => { + const player = new FakeLoopPlayer() + const track = createLoopTrack() + const switches = [ + switchLoop(track, 'passo', 1, player), + switchLoop(track, 'corrida', 1, player), + ] + await player.finishDownloads() + await Promise.all(switches) + expect(player.started.map((source) => source.url)).toEqual(['corrida']) + }) + + it('pedir silêncio para o laço e não começa nada', async () => { + const player = new FakeLoopPlayer() + const track = createLoopTrack() + const first = switchLoop(track, 'passo', 1, player) + await player.finishDownloads() + await first + await switchLoop(track, undefined, 1, player) + expect(player.playing()).toEqual([]) + }) + + it('A→B→A durante o download deixa um laço só tocando', async () => { + // regressão: os dois pedidos de A passavam pela comparação da url, e a + // primeira fonte ficava em laço para sempre (revisão do PR #31). + const player = new FakeLoopPlayer() + const track = createLoopTrack() + const switches = [ + switchLoop(track, 'passo', 1, player), + switchLoop(track, 'corrida', 1, player), + switchLoop(track, 'passo', 1, player), + ] + await player.finishDownloads() + await Promise.all(switches) + expect(player.started).toHaveLength(1) + expect(track.source).toBe(player.started[0]) + }) + + it('A→silêncio→A durante o download deixa um laço só tocando', async () => { + const player = new FakeLoopPlayer() + const track = createLoopTrack() + const switches = [ + switchLoop(track, 'passo', 1, player), + switchLoop(track, undefined, 1, player), + switchLoop(track, 'passo', 1, player), + ] + await player.finishDownloads() + await Promise.all(switches) + expect(player.started).toHaveLength(1) + }) +}) diff --git a/packages/client/src/audio/loopSwitch.ts b/packages/client/src/audio/loopSwitch.ts new file mode 100644 index 00000000..4086f912 --- /dev/null +++ b/packages/client/src/audio/loopSwitch.ts @@ -0,0 +1,56 @@ +import { createLatestRequest, type LatestRequest } from './latestRequest.ts' + +/** + * Um laço que troca de som: o que ele toca agora, e qual dos pedidos de troca + * ainda vale. Mora fora de `webAudioSpatial.ts` para a regra da troca ser + * testada sem navegador — o adapter só sabe criar e parar a fonte. + */ +export interface LoopTrack { + url: string | undefined + rate: number + source: Source | undefined + /** A troca pedida por último: só ela começa uma fonte depois do download. */ + readonly latest: LatestRequest +} + +/** O que a troca precisa do motor de áudio: baixar, começar e parar. */ +export interface LoopPlayer { + bufferOf(url: string): Promise + start(buffer: Buffer, rate: number): Source + stop(source: Source): void +} + +export function createLoopTrack(): LoopTrack { + return { url: undefined, rate: 1, source: undefined, latest: createLatestRequest() } +} + +/** + * Troca o laço (quem chama já sabe que o pedido mudou: o mesmo pedido de + * sempre não mexe no laço, e é o que permite chamar por quadro sem picotar a + * passada). A marca e o número do pedido entram antes do `await`. + * + * Comparar só a url depois do `await` não bastava: A→B→A, ou A→nada→A, com o + * primeiro download ainda correndo, deixava os dois pedidos de A passarem, e a + * primeira fonte ficava em laço para sempre — o passo que nunca parava + * (revisão do PR #31). Com o número, só o último pedido começa uma fonte. + * + * ```ts + * void switchLoop(track, footstepUrl, 1.2, player) + * ``` + */ +export async function switchLoop( + track: LoopTrack, + url: string | undefined, + rate: number, + player: LoopPlayer, +): Promise { + if (track.source !== undefined) player.stop(track.source) + track.url = url + track.rate = rate + track.source = undefined + const ticket = track.latest.stamp() + if (!url) return + const buffer = await player.bufferOf(url) + if (!track.latest.isLatest(ticket)) return + track.source = player.start(buffer, rate) +} diff --git a/packages/client/src/audio/webAudioSpatial.ts b/packages/client/src/audio/webAudioSpatial.ts index b9a7357a..161b7350 100644 --- a/packages/client/src/audio/webAudioSpatial.ts +++ b/packages/client/src/audio/webAudioSpatial.ts @@ -1,5 +1,6 @@ import type { Vector3 } from '@shobu/core' import { toWebAudioSpace } from './listenerSpace.ts' +import { createLoopTrack, type LoopPlayer, type LoopTrack, switchLoop } from './loopSwitch.ts' import { busyUntil, pickVoice, type VoiceSlot } from './voicePool.ts' /** @@ -55,10 +56,9 @@ interface OneShotVoice extends Chain, VoiceSlot { } interface LoopVoice extends Chain { - url: string | undefined - rate: number - source: AudioBufferSourceNode | undefined - started: boolean + readonly track: LoopTrack + /** Como esta voz começa e para a fonte: nasce com a voz, e não por troca. */ + readonly player: LoopPlayer } export function createSpatialVoices( @@ -127,12 +127,12 @@ function loopAtPoint( gain: number, rate: number, ): void { - const voice = pool.loops.get(name) ?? openLoop(pool.context, pool.destination, pool.loops, name) + const voice = pool.loops.get(name) ?? openLoop(pool, name) aimChain(pool, voice, positionM, gain) // o caso de todo quadro é o laço de sempre, só andando: a guarda vem antes // da função assíncrona, que criaria duas promessas por bot por quadro. - if (voice.url === url && voice.rate === rate) return - void switchLoop(voice, url, rate, pool.bufferOf).catch(() => {}) + if (voice.track.url === url && voice.track.rate === rate) return + void switchLoop(voice.track, url, rate, voice.player).catch(() => {}) } /** Põe a corrente no ponto, com o ganho da distância. */ @@ -225,49 +225,31 @@ function startOneShot( voice.busyUntilS = busyUntil(nowS, delayS, buffer.duration, offsetS, 1) } -function openLoop( - context: AudioContext, - destination: AudioNode, - loops: Map, - name: string, -): LoopVoice { +function openLoop(pool: SpatialPool, name: string): LoopVoice { + const chain = createChain(pool.context, pool.destination) const voice: LoopVoice = { - ...createChain(context, destination), - url: undefined, - rate: 1, - source: undefined, - started: false, + ...chain, + track: createLoopTrack(), + player: { + bufferOf: pool.bufferOf, + start: (buffer, rate) => startLoopSource(chain.panner, buffer, rate), + stop: (source) => source.stop(), + }, } - loops.set(name, voice) + pool.loops.set(name, voice) return voice } -/** - * Troca o laço (quem chama já sabe que o pedido mudou: o mesmo pedido de - * sempre não mexe no laço, e é o que permite chamar por quadro sem picotar a - * passada). A marca entra antes do `await`: duas trocas no mesmo quadro não - * deixam dois laços tocando. - */ -async function switchLoop( - voice: LoopVoice, - url: string | undefined, +function startLoopSource( + panner: PannerNode, + buffer: AudioBuffer, rate: number, - bufferOf: (url: string) => Promise, -): Promise { - if (voice.started) voice.source?.stop() - voice.url = url - voice.rate = rate - voice.started = false - voice.source = undefined - if (!url) return - const buffer = await bufferOf(url) - if (voice.url !== url || voice.rate !== rate) return - const source = voice.panner.context.createBufferSource() +): AudioBufferSourceNode { + const source = panner.context.createBufferSource() source.buffer = buffer source.loop = true source.playbackRate.value = rate - source.connect(voice.panner) + source.connect(panner) source.start() - voice.source = source - voice.started = true + return source } diff --git a/packages/client/src/controller/arenaCombat.ts b/packages/client/src/controller/arenaCombat.ts index b74ac9d9..0b866339 100644 --- a/packages/client/src/controller/arenaCombat.ts +++ b/packages/client/src/controller/arenaCombat.ts @@ -141,7 +141,9 @@ function stepBot(combat: TickCombat, bot: BotPlayer, botIndex: number): void { } const fighterIndex = botIndex + 1 if (aimAndTrigger(bot, fighterIndex, parts.botTick)) fireBot(combat, bot, botIndex) - if (bot.melee.struckThisTick) strikeFrom(combat, fighterIndex, bot.eyeM, bot.aimM, 'knife') + if (bot.melee.struckThisTick) { + strikeFrom(combat, fighterIndex, bot.eyeM, bot.aimM, bot.melee.weapon) + } moveBot(bot, parts.botTick) } @@ -236,11 +238,14 @@ function scoreKill( * * A luneta fecha **aqui**, e não no respawn: o tick do jogador morto não anda * a arma, e quem morria mirando via a tela de morte inteira pela luneta (11/10). + * O aviso de alcance do gancho apaga pelo mesmo motivo: o tick morto não o + * recalcula. */ function freezeLocalDeath(combat: TickCombat, killerId: string): void { const { session, options, parts } = combat options.character.holdStill() closeScope(parts.weapon) + session.grappleInReach = false session.killedBy = killerId parts.boundary.outsideS = 0 session.boundaryRemainingS = null diff --git a/packages/client/src/controller/arenaMelee.test.ts b/packages/client/src/controller/arenaMelee.test.ts index ae1cd780..3830deff 100644 --- a/packages/client/src/controller/arenaMelee.test.ts +++ b/packages/client/src/controller/arenaMelee.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { + type BotPlayer, boxFromCenterSize, createNavGraph, killsWithin, @@ -125,6 +126,8 @@ describe('corpo a corpo na sessão', () => { /** O relato de 11/10: morrer de luneta aberta deixava a luneta aberta até o respawn. */ it('morrer de luneta aberta fecha a luneta já na tela de morte', () => { const rig = mountMelee({ hostileBots: true }) + // a partida nova pede um aperto novo (`rearmWeapon`): o quadro solto vem antes. + rig.frames() rig.weaponInput.scope = true rig.frames() rig.weaponInput.scope = false @@ -134,6 +137,16 @@ describe('corpo a corpo na sessão', () => { expect(rig.session.weapon.scoped).toBe(false) }) + it('o golpe do bot sai com a lâmina dele, e não sempre com a faca', () => { + // regressão: o golpe do bot era resolvido com 'knife' cravado (revisão do PR #31). + const rig = mountMelee({ hostileBots: true }) + const bot = rig.session.bots[0] as BotPlayer + bot.melee.weapon = 'katana' + for (let frame = 0; frame < 3 / FRAME_S && rig.session.local.alive; frame += 1) rig.frames() + expect(rig.session.local.alive).toBe(false) + expect(rig.session.deathCause.weapon).toBe('katana') + }) + it('renascer volta à sniper', () => { const rig = mountMelee({ hostileBots: true }) rig.session.restartMatch({ diff --git a/packages/client/src/controller/arenaRestart.test.ts b/packages/client/src/controller/arenaRestart.test.ts index 84ef101f..0de653b2 100644 --- a/packages/client/src/controller/arenaRestart.test.ts +++ b/packages/client/src/controller/arenaRestart.test.ts @@ -1,4 +1,7 @@ +import { boxFromCenterSize, nextUnit, type StaticBox } from '@shobu/core' import { describe, expect, it } from 'vitest' +import { type ArenaParts, createArenaParts } from './arenaParts.ts' +import { seatBotsOnTheBoard } from './arenaRestart.ts' import { LOCAL_PLAYER_ID } from './arenaSession.ts' import { config, @@ -8,8 +11,11 @@ import { type Rig, ROOM_RULES, SPAWN_FEET, + sessionOptionsFor, settleScope, } from './arenaSessionTestKit.ts' +import { createWeaponInput } from './weaponInputFrom.ts' +import { createMovementInput } from './wishDirection.ts' describe('restartMatch', () => { /** @@ -66,8 +72,11 @@ describe('fim de partida', () => { /** O maior quadro que o acumulador aceita inteiro (`maxTicksPerFrame`). */ const LONG_FRAME_S = 0.1 - function mountTimed(): Rig { - const rig = mountSession([FLOOR], { config: SHORT }) + /** Uma parede 30 m à frente: dentro do alcance do gancho. */ + const WALL = boxFromCenterSize([0, 10, 30.5], [40, 20, 1]) + + function mountTimed(boxes: readonly StaticBox[] = [FLOOR]): Rig { + const rig = mountSession(boxes, { config: SHORT }) rig.session.restartMatch(TIMED) return rig } @@ -115,6 +124,25 @@ describe('fim de partida', () => { expect(rig.session.weapon.scoped).toBe(false) }) + it('solta o gancho de quem estava na corda: a corda não fica atrás da tela do MVP', () => { + const rig = mountTimed([FLOOR, WALL]) + runFor(rig, 29.5) + rig.movementInput.grapple = true + rig.frame() + expect(rig.session.character.current.grappleAttached).toBe(true) + runFor(rig, 1) + expect(rig.session.matchOver).toBe(true) + expect(rig.session.character.current.grappleAttached).toBe(false) + }) + + it('apaga o aviso de alcance do gancho', () => { + const rig = mountTimed([FLOOR, WALL]) + runFor(rig, 29.5) + expect(rig.session.grappleInReach).toBe(true) + runFor(rig, 1) + expect(rig.session.grappleInReach).toBe(false) + }) + it('a partida seguinte reabre o relógio', () => { const rig = mountTimed() runFor(rig, 31) @@ -124,3 +152,38 @@ describe('fim de partida', () => { expect(rig.session.matchTimeS).toBeGreaterThan(0) }) }) + +describe('seatBotsOnTheBoard', () => { + function openParts(): ArenaParts { + const inputs = { weaponInput: createWeaponInput(), movementInput: createMovementInput() } + return createArenaParts(sessionOptionsFor([FLOOR], { humans: 1 }, inputs)) + } + + /** Quantas tiradas a semente andou de `before` até `after`. */ + function drawsBetween(before: number, after: number): number { + const rng = { state: before } + let draws = 0 + while (rng.state !== after && draws < 100) { + nextUnit(rng) + draws += 1 + } + return draws + } + + function drawsToSeat(parts: ArenaParts, count: number): number { + const before = parts.squad.rng.state + seatBotsOnTheBoard(parts, config, count) + return drawsBetween(before, parts.squad.rng.state) + } + + it('quem volta do banco é posto uma vez só, como quem já estava sentado', () => { + // regressão: o bot que voltava do banco ganhava spawn no `seatBots` e de + // novo aqui, e as tiradas a mais deslocavam os spawns seguintes (revisão do PR #31). + const staying = openParts() + seatBotsOnTheBoard(staying, config, 3) + const returning = openParts() + seatBotsOnTheBoard(returning, config, 0) + expect(drawsToSeat(returning, 3)).toBe(drawsToSeat(staying, 3)) + expect(returning.squad.bots.every((bot) => bot.alive)).toBe(true) + }) +}) diff --git a/packages/client/src/controller/arenaRestart.ts b/packages/client/src/controller/arenaRestart.ts index ddc22088..4742fa59 100644 --- a/packages/client/src/controller/arenaRestart.ts +++ b/packages/client/src/controller/arenaRestart.ts @@ -77,15 +77,25 @@ function respawnLocal( * Senta os bots da contagem, cada um num spawn novo, e tira do placar os que * foram para o banco: o tab não mostra quem não está na sala. Quem volta do * banco entra no fim do placar. + * + * Cada bot é posto **uma** vez: quem volta do banco já ganhou spawn no + * `seatBots`, e pô-lo de novo gastava duas tiradas da semente e deslocava o + * spawn de todos os seguintes (revisão do PR #31). Exportada para o teste + * contar as tiradas. + * + * ```ts + * seatBotsOnTheBoard(parts, config, rules.botCount) + * ``` */ -function seatBotsOnTheBoard(parts: ArenaParts, config: GameplayConfig, count: number): void { +export function seatBotsOnTheBoard(parts: ArenaParts, config: GameplayConfig, count: number): void { + const returning = parts.squad.bots.filter(isBenched) seatBots(parts.squad, parts.fighters, count, config) for (const bot of parts.squad.bots) { if (isBenched(bot)) { removePlayer(parts.scoreboard, bot.id) continue } - placeBot(parts.squad, bot, parts.fighters, config) + if (!returning.includes(bot)) placeBot(parts.squad, bot, parts.fighters, config) addPlayer(parts.scoreboard, bot.id) } } @@ -93,18 +103,21 @@ function seatBotsOnTheBoard(parts: ArenaParts, config: GameplayConfig, count: nu /** * O tempo acabou: a arma volta pronta — o que fecha a luneta e desfaz uma * recarga pela metade — e a lâmina volta à bainha. Daqui em diante o tick - * não roda mais (`matchOver`). + * não roda mais (`matchOver`), então o corpo para como na morte: a corda do + * gancho presa ficava desenhada atrás da tela do MVP (revisão do PR #31). * * ```ts - * endMatch(session, config, parts) + * endMatch(session, options, parts) * ``` */ export function endMatch( session: ArenaSessionClocks, - config: GameplayConfig, + options: ArenaSessionOptions, parts: ArenaParts, ): void { session.matchOver = true - rearmWeapon(parts.weapon, config) + session.grappleInReach = false + options.character.holdStill() + rearmWeapon(parts.weapon, options.config) rearmMelee(parts.melee) } diff --git a/packages/client/src/controller/arenaSession.test.ts b/packages/client/src/controller/arenaSession.test.ts index 5b7bebaf..3c51057c 100644 --- a/packages/client/src/controller/arenaSession.test.ts +++ b/packages/client/src/controller/arenaSession.test.ts @@ -105,6 +105,8 @@ describe('createArenaSession', () => { /** O tiro vale só no quadro dele: guardar o ponteiro é erro de quem guarda. */ it('o tiro do quadro some no quadro seguinte', () => { const rig = mountSession() + // a partida nova pede um aperto novo (`rearmWeapon`): o quadro solto vem antes. + rig.frame() expect(pullTrigger(rig)).toBeDefined() expect(rig.session.lastShot).toBeUndefined() }) @@ -115,6 +117,8 @@ describe('createArenaSession', () => { */ it('a mira lenta vale no mesmo tick em que a mira abre', () => { const rig = mountSession() + // a partida nova pede um aperto novo (`rearmWeapon`): o quadro solto vem antes. + rig.frame() rig.weaponInput.scope = true rig.frame() expect(rig.movementInput.scoped).toBe(true) @@ -139,6 +143,8 @@ describe('createArenaSession', () => { it('o pente esvazia a cada tiro e recarrega sozinho no último', () => { const rig = mountSession() + // a partida nova pede um aperto novo (`rearmWeapon`): o quadro solto vem antes. + rig.frame() for (let shot = 0; shot < config.weapon.magazineRounds; shot += 1) { pullTrigger(rig) const boltFrames = Math.ceil(config.weapon.boltCycleS / FRAME_S) + 1 @@ -480,6 +486,19 @@ describe('gancho na sessão', () => { expect(rig.session.grappleInReach).toBe(false) }) + it('a morte apaga o aviso de alcance do gancho', () => { + // regressão: o tick morto não recalcula o aviso, e a retícula prometia + // gancho a tela de morte inteira (revisão do PR #31). + const rig = mountSession([FLOOR, WALL], { hostileBots: true }) + let promised = false + for (let frame = 0; frame < 20 / FRAME_S && rig.session.local.alive; frame += 1) { + rig.frame({ aimM: { x: 0, y: 0.2, z: 1 } }) + promised ||= rig.session.grappleInReach + } + expect([promised, rig.session.local.alive]).toEqual([true, false]) + expect(rig.session.grappleInReach).toBe(false) + }) + it('a kill com a corda presa ganha a na-corda', () => { const rig = mountSession([FLOOR, WALL]) settleScope(rig) diff --git a/packages/client/src/controller/arenaSession.ts b/packages/client/src/controller/arenaSession.ts index 753717b0..239a7c4d 100644 --- a/packages/client/src/controller/arenaSession.ts +++ b/packages/client/src/controller/arenaSession.ts @@ -281,7 +281,10 @@ function advanceArena( frame: Readonly, ): number { session.lastShot = undefined + // as medalhas do quadro inteiro, e não só do último tick: `recordKill` + // acrescenta, e é só aqui que elas começam do zero. parts.medals.length = 0 + parts.botMedals.length = 0 parts.kills.count = 0 parts.tracers.count = 0 const ticks = options.character.pendingTicks(frame.elapsedS) @@ -319,7 +322,7 @@ function endMatchOnTime(combat: TickCombat): void { const durationS = options.config.match.durationS if (session.matchTimeS < durationS - options.character.tickDurationS / 2) return session.matchTimeS = durationS - endMatch(session, options.config, parts) + endMatch(session, options, parts) } /** diff --git a/packages/client/src/controller/arenaSessionTestKit.ts b/packages/client/src/controller/arenaSessionTestKit.ts index 7fd89b4d..b25e6ede 100644 --- a/packages/client/src/controller/arenaSessionTestKit.ts +++ b/packages/client/src/controller/arenaSessionTestKit.ts @@ -9,7 +9,12 @@ import { type StaticBox, type Vector3, } from '@shobu/core' -import { type ArenaFrame, type ArenaSession, createArenaSession } from './arenaSession.ts' +import { + type ArenaFrame, + type ArenaSession, + type ArenaSessionOptions, + createArenaSession, +} from './arenaSession.ts' import { createLocalCharacter } from './localCharacter.ts' import { createWeaponInput, type MutableWeaponInput } from './weaponInputFrom.ts' import { createMovementInput, type MutableMovementInput } from './wishDirection.ts' @@ -153,8 +158,20 @@ function openSession( boundary: BoundaryRig, inputs: { weaponInput: MutableWeaponInput; movementInput: MutableMovementInput }, ): ArenaSession { + return createArenaSession(sessionOptionsFor(boxes, boundary, inputs)) +} + +/** + * As opções da sala dos testes, sem abrir a sessão: é o que os testes das + * peças de dentro (`createArenaParts`) recebem. + */ +export function sessionOptionsFor( + boxes: readonly StaticBox[], + boundary: BoundaryRig, + inputs: { weaponInput: MutableWeaponInput; movementInput: MutableMovementInput }, +): ArenaSessionOptions { const rigConfig = boundary.config ?? config - return createArenaSession({ + return { config: rigConfig, character: createLocalCharacter(rigConfig, SPAWN_FEET, boxes), boxes, @@ -164,5 +181,5 @@ function openSession( // o jogador nasce no primeiro, então o bot fica com o posto. respawnFeetM: boundary.respawnFeetM ?? [SPAWN_FEET, BOT_POST], humans: boundary.humans ?? config.match.playersPerRoom - 1, - }) + } } diff --git a/packages/client/src/controller/localCharacter.test.ts b/packages/client/src/controller/localCharacter.test.ts index bbe1f38a..261412e5 100644 --- a/packages/client/src/controller/localCharacter.test.ts +++ b/packages/client/src/controller/localCharacter.test.ts @@ -144,4 +144,15 @@ describe('holdStill', () => { character.holdStill() expect(character.current.crouchToggled).toBe(false) }) + + it('a morte solta o gancho: a corda não fica desenhada na tela de morte', () => { + const character = createLocalCharacter(config, SPAWN_FEET, boxes) + Object.assign(character.current, { grappleAttached: true, grappleAttachedS: 0.5 }) + character.holdStill() + expect([character.current.grappleAttached, character.current.grappleAttachedS]).toEqual([ + false, + 0, + ]) + expect(character.previous.grappleAttached).toBe(false) + }) }) diff --git a/packages/client/src/controller/localCharacter.ts b/packages/client/src/controller/localCharacter.ts index 5a568ac3..400fa217 100644 --- a/packages/client/src/controller/localCharacter.ts +++ b/packages/client/src/controller/localCharacter.ts @@ -129,6 +129,10 @@ function holdStill(current: CharacterState, previous: CharacterState): void { // a morte apaga o agachar alternado: o respawn já o zeraria, mas o botão de // toque aceso durante a tela de morte dizia que o pedido sobrevivia. current.crouchToggled = false + // e solta o gancho: a corda presa seguia desenhada a tela de morte inteira, + // e atrás da tela do MVP no fim da partida (revisão do PR #31). + current.grappleAttached = false + current.grappleAttachedS = 0 current.velocity.x = 0 current.velocity.y = 0 current.velocity.z = 0 diff --git a/packages/core/src/bots/botAim.test.ts b/packages/core/src/bots/botAim.test.ts index 90894068..211f7153 100644 --- a/packages/core/src/bots/botAim.test.ts +++ b/packages/core/src/bots/botAim.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import type { Fighter } from '../match/fighter.ts' -import { eyeOf } from '../match/fighter.ts' -import { createCharacterState } from '../movement/characterState.ts' +import { createCharacterState, eyeOf } from '../movement/characterState.ts' import { IDLE_WEAPON_INPUT } from '../weapon/weaponInput.ts' import { aimAlongStride, diff --git a/packages/core/src/bots/botSquad.ts b/packages/core/src/bots/botSquad.ts index ed8f38d1..2eea1446 100644 --- a/packages/core/src/bots/botSquad.ts +++ b/packages/core/src/bots/botSquad.ts @@ -1,10 +1,10 @@ import type { StaticBox } from '../collision/staticBox.ts' import type { GameplayConfig } from '../config/gameplayConfig.ts' -import { eyeOf, type Fighter, pickSpawn } from '../match/fighter.ts' +import { type Fighter, pickSpawn } from '../match/fighter.ts' import { createSeededRandom, nextUnit, type SeededRandom } from '../math/seededRandom.ts' import type { Vector3 } from '../math/vector3.ts' import { armMovementInput, stepArsenal } from '../melee/stepArsenal.ts' -import { copyCharacterState } from '../movement/characterState.ts' +import { copyCharacterState, eyeOf } from '../movement/characterState.ts' import { stepCharacter } from '../movement/stepCharacter.ts' import { aimAlongStride, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5925d58b..f2087662 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -91,7 +91,6 @@ export { parseGameplayConfig } from './config/parseGameplayConfig.ts' export type { Fighter } from './match/fighter.ts' export { collectFighterTargets, - eyeOf, killFighter, pickSpawn, stepRespawnClock, @@ -126,6 +125,7 @@ export { isInsidePlayArea, stepBoundary, } from './match/playArea.ts' +export { fnv1a32 } from './math/fnv1a.ts' export type { SeededRandom } from './math/seededRandom.ts' export { createSeededRandom, nextUnit } from './math/seededRandom.ts' export type { Vector3 } from './math/vector3.ts' @@ -158,12 +158,13 @@ export { copyCharacterState, createCharacterState, EYE_HEIGHT_RATIO, + eyeOf, horizontalSpeed, JUMPS_PER_FLIGHT, respawnCharacter, } from './movement/characterState.ts' export { CROUCH_CAPSULE_RATIO } from './movement/crouch.ts' -export { findGrappleAnchor, grappleEyeM } from './movement/grapple.ts' +export { findGrappleAnchor } from './movement/grapple.ts' export { maxDoubleJumpHeightM, maxJumpHeightM } from './movement/jump.ts' export type { MovementInput } from './movement/movementInput.ts' export { hasWish, IDLE_INPUT, wishLength, wishUnit } from './movement/movementInput.ts' diff --git a/packages/core/src/match/fighter.test.ts b/packages/core/src/match/fighter.test.ts index 971352c2..4ff79663 100644 --- a/packages/core/src/match/fighter.test.ts +++ b/packages/core/src/match/fighter.test.ts @@ -2,10 +2,9 @@ import { describe, expect, it } from 'vitest' import { config, standingFighter } from '../bots/botTestKit.ts' import { createTargetList } from '../collision/targetList.ts' import { createSeededRandom } from '../math/seededRandom.ts' -import { EYE_HEIGHT_RATIO } from '../movement/characterState.ts' +import { EYE_HEIGHT_RATIO, eyeOf } from '../movement/characterState.ts' import { collectFighterTargets, - eyeOf, type Fighter, killFighter, pickSpawn, diff --git a/packages/core/src/match/fighter.ts b/packages/core/src/match/fighter.ts index da566b1c..d174ed13 100644 --- a/packages/core/src/match/fighter.ts +++ b/packages/core/src/match/fighter.ts @@ -2,7 +2,7 @@ import { pushTarget, resetTargetList, type TargetList } from '../collision/targe import type { GameplayConfig } from '../config/gameplayConfig.ts' import { nextUnit, type SeededRandom } from '../math/seededRandom.ts' import type { Vector3 } from '../math/vector3.ts' -import { type CharacterState, EYE_HEIGHT_RATIO } from '../movement/characterState.ts' +import type { CharacterState } from '../movement/characterState.ts' /** * Quem está na arena e pode atirar e morrer: o jogador local, um jogador da @@ -81,20 +81,6 @@ export function stepRespawnClock(fighter: Fighter, dtS: number): boolean { return true } -/** - * O olho de um corpo, sem balanço de câmera: é de onde o tiro sai (ADR 0002). - * - * ```ts - * eyeOf(bot.body, bot.eyeM) - * ``` - */ -export function eyeOf(body: Readonly, out: Vector3): Vector3 { - out.x = body.position.x - out.y = body.position.y + body.capsuleHeightM * EYE_HEIGHT_RATIO - out.z = body.position.z - return out -} - /** * Perto disto de alguém vivo, o spawn está ocupado. Não é proteção de spawn, * que o pilar 1 proíbe — é não nascer **dentro** de outro corpo, já que corpos diff --git a/packages/core/src/match/mapVote.test.ts b/packages/core/src/match/mapVote.test.ts index ba93d7bd..5cf5c950 100644 --- a/packages/core/src/match/mapVote.test.ts +++ b/packages/core/src/match/mapVote.test.ts @@ -60,6 +60,14 @@ describe('botBallots', () => { const two = botBallots(BOTS.slice(0, 2), OPTIONS, 9, WINDOW) for (const ballot of two) expect(three).toContainEqual(ballot) }) + + it('mandar o bot do meio para o banco não muda o voto dos que vêm depois', () => { + // regressão: a semente vinha da posição na lista, e o banco a deslocava + // para todos os seguintes (revisão do PR #31). + const three = botBallots(BOTS, OPTIONS, 9, WINDOW) + const benched = botBallots(['bot-0', 'bot-2'], OPTIONS, 9, WINDOW) + for (const ballot of benched) expect(three).toContainEqual(ballot) + }) }) describe('castBallot', () => { diff --git a/packages/core/src/match/mapVote.ts b/packages/core/src/match/mapVote.ts index 1305ea53..4ac545b3 100644 --- a/packages/core/src/match/mapVote.ts +++ b/packages/core/src/match/mapVote.ts @@ -1,3 +1,4 @@ +import { fnv1a32 } from '../math/fnv1a.ts' import { createSeededRandom, nextUnit, type SeededRandom } from '../math/seededRandom.ts' /** @@ -62,6 +63,10 @@ export function mapVoteOptions(mapIds: readonly string[]): readonly string[] { * sequência da semente — o primeiro número escolhe a opção, o segundo o * instante —, então tirar ou pôr um bot não muda o voto dos outros. * + * A sequência sai do **id** do bot, e não da posição dele na lista: com a + * posição, mandar um bot do meio para o banco deslocava a semente de todos os + * que vinham depois (revisão do PR #31). + * * ```ts * botBallots(['bot-0', 'bot-1'], options, matchSeed, { fromS: 1, untilS: 7 }) * ``` @@ -72,8 +77,8 @@ export function botBallots( seed: number, window: Readonly, ): readonly MapBallot[] { - const ballots = botIds.map((voterId, index) => { - const rng = createSeededRandom(seed + BOT_SEED_STRIDE * (index + 1)) + const ballots = botIds.map((voterId) => { + const rng = createSeededRandom(seed + Math.imul(BOT_SEED_STRIDE, fnv1a32(voterId))) const optionId = pick(optionIds, rng) const atS = window.fromS + nextUnit(rng) * (window.untilS - window.fromS) return { voterId, optionId, atS } diff --git a/packages/core/src/math/fnv1a.test.ts b/packages/core/src/math/fnv1a.test.ts new file mode 100644 index 00000000..893e4911 --- /dev/null +++ b/packages/core/src/math/fnv1a.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { fnv1a32 } from './fnv1a.ts' + +describe('fnv1a32', () => { + it('bate com os vetores de referência do FNV-1a de 32 bits', () => { + expect(fnv1a32('')).toBe(0x811c9dc5) + expect(fnv1a32('a')).toBe(0xe40c292c) + expect(fnv1a32('foobar')).toBe(0xbf9cf968) + }) + + it('textos diferentes, números diferentes', () => { + expect(fnv1a32('bot-0')).not.toBe(fnv1a32('bot-1')) + }) +}) diff --git a/packages/core/src/math/fnv1a.ts b/packages/core/src/math/fnv1a.ts new file mode 100644 index 00000000..598da2f7 --- /dev/null +++ b/packages/core/src/math/fnv1a.ts @@ -0,0 +1,17 @@ +/** + * FNV-1a de 32 bits sobre o texto, por unidade de código. É o hash curto e + * determinístico do projeto: o mesmo texto dá o mesmo número no node, no + * navegador e, amanhã, no servidor. Não é criptografia. + * + * ```ts + * fnv1a32('bot-0') // sempre o mesmo inteiro sem sinal + * ``` + */ +export function fnv1a32(text: string): number { + let hash = 0x811c9dc5 + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return hash +} diff --git a/packages/core/src/medals/awardMedals.test.ts b/packages/core/src/medals/awardMedals.test.ts index 552c632d..07e3fec1 100644 --- a/packages/core/src/medals/awardMedals.test.ts +++ b/packages/core/src/medals/awardMedals.test.ts @@ -8,7 +8,7 @@ import { resetScoreboard, type Scoreboard, } from '../scoring/scoreboard.ts' -import { awardMedals, createMedalAwards } from './awardMedals.ts' +import { awardMedals, createMedalAwards, medalPointsFrom } from './awardMedals.ts' import type { MedalSlug } from './medalCatalog.ts' /** @@ -320,13 +320,29 @@ describe('crédito no placar', () => { expect(devolvido).toBe(into) }) - it('limpa o vetor entre kills: a medalha de ontem não fica no feed', () => { + it('acrescenta ao vetor, sem apagar a kill anterior do mesmo quadro', () => { + // regressão: a segunda kill de um quadro de vários ticks apagava as + // medalhas da primeira antes de a tela lê-las (revisão do PR #31). const board = createScoreboard() const into = createMedalAwards() awardMedals(board, killOf({ scoped: false }), MEDALS, into) + const first = into.map((award) => award.medal.slug) applyKill(board, killOf({ scoped: false }), POINTS_PER_KILL) - awardMedals(board, killOf({ atS: 900, scoped: true }), MEDALS, into) - expect(into.map((award) => award.medal.slug)).toEqual([]) + awardMedals(board, killOf({ atS: 900, scoped: false }), MEDALS, into) + expect(first.length).toBeGreaterThan(0) + expect(into.length).toBeGreaterThan(first.length) + expect(into.slice(0, first.length).map((award) => award.medal.slug)).toEqual(first) + }) + + it('credita no placar só os pontos das medalhas da kill nova', () => { + const board = createScoreboard() + const into = createMedalAwards() + awardMedals(board, killOf({ scoped: false }), MEDALS, into) + applyKill(board, killOf({ scoped: false }), POINTS_PER_KILL) + const before = board.players.get('a')?.points ?? 0 + const firstOfKill = into.length + awardMedals(board, killOf({ atS: 900, scoped: false }), MEDALS, into) + expect(board.players.get('a')?.points).toBe(before + medalPointsFrom(into, firstOfKill)) }) }) diff --git a/packages/core/src/medals/awardMedals.ts b/packages/core/src/medals/awardMedals.ts index db72d852..582ea78d 100644 --- a/packages/core/src/medals/awardMedals.ts +++ b/packages/core/src/medals/awardMedals.ts @@ -53,27 +53,54 @@ const IMPLIES: Readonly>> = { backstab: ['knife'], } +/** + * As medalhas da kill entram **no fim** de `into`, sem apagar o que já estava: + * quem limpa é quem chama, uma vez por quadro. Um quadro que roda vários + * ticks pode ter duas kills do jogador, e apagar aqui sumia com as medalhas da + * primeira antes de a tela e o narrador as lerem (revisão do PR #31). + * + * ```ts + * medals.length = 0 // no começo do quadro + * awardMedals(board, kill, config.medals, medals) + * ``` + */ export function awardMedals( board: Scoreboard, event: Readonly, config: MedalsConfig, into: MedalAward[], ): readonly MedalAward[] { - into.length = 0 if (event.shooterId === event.victimId) return into + const firstOfKill = into.length const subject = subjectOf(board, event) resetLapsedChain(subject.shooter, event.atS, config) const earned = MEDAL_RULES.filter((rule) => rule.holds(subject, config)).map((rule) => rule.slug) for (const slug of keepHighest(earned)) { into.push({ medal: medalBySlug(slug), points: pointsFor(slug, subject.shooter, config) }) } - addPlayer(board, event.shooterId).points += into.reduce((sum, award) => sum + award.points, 0) + addPlayer(board, event.shooterId).points += medalPointsFrom(into, firstOfKill) // a kill que vem logo depois sobe a revisão de novo; esta é para quem // chama as medalhas sem a kill, e o placar ainda assim mudou. - if (into.length > 0) board.revision += 1 + if (into.length > firstOfKill) board.revision += 1 return into } +/** + * Os pontos das medalhas a partir de `first`: as da última kill, quando o + * vetor acumula as do quadro inteiro. Laço e não `slice`, que alocaria. + * + * ```ts + * const bonus = medalPointsFrom(medals, medals.length - awardedNow) + * ``` + */ +export function medalPointsFrom(awards: readonly MedalAward[], first: number): number { + let points = 0 + for (let index = first; index < awards.length; index += 1) { + points += (awards[index] as MedalAward).points + } + return points +} + function subjectOf(board: Scoreboard, event: Readonly): MedalSubject { return { event, diff --git a/packages/core/src/movement/characterState.ts b/packages/core/src/movement/characterState.ts index 0fe78c14..d4fc91d0 100644 --- a/packages/core/src/movement/characterState.ts +++ b/packages/core/src/movement/characterState.ts @@ -22,6 +22,22 @@ export type Stance = 'standing' | 'crouching' | 'sliding' */ export const EYE_HEIGHT_RATIO = 0.92 +/** + * O olho de um corpo, sem balanço de câmera: é de onde o tiro sai (ADR 0002) + * e de onde o cabo do gancho parte (ADR 0018). Uma conta só para os dois, e + * mora no movimento porque `match/` já depende daqui, e não o contrário. + * + * ```ts + * eyeOf(bot.body, bot.eyeM) // out.y === position.y + 0.92 · capsuleHeightM + * ``` + */ +export function eyeOf(body: Readonly, out: Vector3): Vector3 { + out.x = body.position.x + out.y = body.position.y + body.capsuleHeightM * EYE_HEIGHT_RATIO + out.z = body.position.z + return out +} + /** Pulo mais pulo duplo: o que recarrega ao tocar o chão. */ export const JUMPS_PER_FLIGHT = 2 diff --git a/packages/core/src/movement/grapple.test.ts b/packages/core/src/movement/grapple.test.ts index 24302d08..a56c006f 100644 --- a/packages/core/src/movement/grapple.test.ts +++ b/packages/core/src/movement/grapple.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from 'vitest' import { COLLIDER_BODY_ONLY, COLLIDER_GRAPPLE_ONLY } from '../collision/colliderMask.ts' import { boxFromCenterSize, type StaticBox } from '../collision/staticBox.ts' import type { Vector3 } from '../math/vector3.ts' -import { type CharacterState, createCharacterState, horizontalSpeed } from './characterState.ts' -import { findGrappleAnchor, grappleEyeM, pullOnRope, takeUpSlack } from './grapple.ts' +import { + type CharacterState, + createCharacterState, + eyeOf, + horizontalSpeed, +} from './characterState.ts' +import { findGrappleAnchor, pullOnRope, takeUpSlack } from './grapple.ts' import { IDLE_INPUT, type MovementInput } from './movementInput.ts' import { FLOOR, @@ -25,8 +30,8 @@ const CEILING: StaticBox = boxFromCenterSize([0, 20.5, 0], [64, 1, 64]) const WORLD: readonly StaticBox[] = [FLOOR, CEILING] const GRAVITY = Math.abs(movement.gravityMps2) -function eyeOf(state: Readonly): Vector3 { - return grappleEyeM(state, { x: 0, y: 0, z: 0 }) +function eyeAt(state: Readonly): Vector3 { + return eyeOf(state, { x: 0, y: 0, z: 0 }) } /** A corda mede do pé (`grapple.ts`), e é do pé que o teste mede também. */ @@ -42,7 +47,7 @@ function aimAt( targetM: Readonly, base: MovementInput = IDLE_INPUT, ): MovementInput { - const eye = eyeOf(state) + const eye = eyeAt(state) return { ...base, grapple: true, diff --git a/packages/core/src/movement/grapple.ts b/packages/core/src/movement/grapple.ts index 34e657a2..51b79c2c 100644 --- a/packages/core/src/movement/grapple.ts +++ b/packages/core/src/movement/grapple.ts @@ -3,7 +3,7 @@ import { createBoxHit, nearestBoxHit } from '../collision/raycastBoxes.ts' import type { StaticBox } from '../collision/staticBox.ts' import type { GameplayConfig } from '../config/gameplayConfig.ts' import { clampLength, dot, type Vector3 } from '../math/vector3.ts' -import { type CharacterState, EYE_HEIGHT_RATIO } from './characterState.ts' +import { type CharacterState, eyeOf } from './characterState.ts' import { tryJump } from './jump.ts' import { type MovementInput, wishUnit } from './movementInput.ts' import { endSlide } from './slide.ts' @@ -58,22 +58,6 @@ export interface GrappleEdges { readonly grapplePressed: boolean } -/** - * De onde o cabo sai: o olho. É o centro da câmera, então o cabo prende - * exatamente onde a retícula estava — mira que acerta na tela e erra no núcleo - * é o defeito que mais irrita num gancho. - * - * ```ts - * grappleEyeM(state, out) // out.y === state.position.y + 0.92 · capsuleHeightM - * ``` - */ -export function grappleEyeM(state: Readonly, out: Vector3): Vector3 { - out.x = state.position.x - out.y = state.position.y + state.capsuleHeightM * EYE_HEIGHT_RATIO - out.z = state.position.z - return out -} - /** * Onde o gancho engataria se fosse disparado agora, ou `undefined` se não há * superfície entre `minRopeLengthM` e `maxRangeM` na mira. Escreve a âncora em @@ -98,7 +82,10 @@ export function findGrappleAnchor( out: Vector3, ): number | undefined { if (!unitAim(aim, aimUnit)) return undefined - grappleEyeM(state, eye) + // o cabo sai do olho, o centro da câmera: prende exatamente onde a retícula + // estava — mira que acerta na tela e erra no núcleo é o defeito que mais + // irrita num gancho. + eyeOf(state, eye) if (!nearestBoxHit(eye, aimUnit, boxes, config.grapple.maxRangeM, STOPS_GRAPPLE, hit)) { return undefined } diff --git a/packages/core/src/scoring/recordKill.test.ts b/packages/core/src/scoring/recordKill.test.ts index 9b872444..0787fc02 100644 --- a/packages/core/src/scoring/recordKill.test.ts +++ b/packages/core/src/scoring/recordKill.test.ts @@ -33,4 +33,17 @@ describe('recordKill', () => { expect(board.players.get('bot-0')?.medals).toBe(medals.length) expect(medals.length).toBeGreaterThan(0) }) + + it('duas kills no mesmo vetor: cada uma conta e pontua só as próprias medalhas', () => { + const board = createScoreboard() + const medals: MedalAward[] = [] + const first = { ...createKillEvent(), shooterId: 'bot-0', victimId: 'local', distanceM: 20 } + const firstPoints = recordKill(board, first, config, medals) + const afterFirst = medals.length + const second = { ...first, victimId: 'bot-1', atS: 1 } + const secondPoints = recordKill(board, second, config, medals) + expect(medals.length).toBeGreaterThan(afterFirst) + expect(board.players.get('bot-0')?.medals).toBe(medals.length) + expect(board.players.get('bot-0')?.points).toBe(firstPoints + secondPoints) + }) }) diff --git a/packages/core/src/scoring/recordKill.ts b/packages/core/src/scoring/recordKill.ts index 4a984369..623d7655 100644 --- a/packages/core/src/scoring/recordKill.ts +++ b/packages/core/src/scoring/recordKill.ts @@ -1,5 +1,5 @@ import type { GameplayConfig } from '../config/gameplayConfig.ts' -import { awardMedals, type MedalAward } from '../medals/awardMedals.ts' +import { awardMedals, type MedalAward, medalPointsFrom } from '../medals/awardMedals.ts' import type { KillEvent } from './killEvent.ts' import { addPlayer, applyKill, type Scoreboard } from './scoreboard.ts' @@ -11,8 +11,8 @@ import { addPlayer, applyKill, type Scoreboard } from './scoreboard.ts' * As medalhas vêm antes porque as condições leem a sequência da vítima, a * contagem da partida e a janela de multikill, e `applyKill` muda as três. * - * Devolve os pontos do atirador, medalhas incluídas, e deixa as medalhas em - * `medalsInto`. + * Devolve os pontos do atirador, medalhas incluídas, e acrescenta as medalhas + * ao fim de `medalsInto` — quem chama o esvazia no começo do quadro. * * ```ts * recordKill(board, kill, config, medals) // 150 @@ -24,8 +24,9 @@ export function recordKill( config: GameplayConfig, medalsInto: MedalAward[], ): number { + const firstOfKill = medalsInto.length awardMedals(board, kill, config.medals, medalsInto) - addPlayer(board, kill.shooterId).medals += medalsInto.length - const bonus = medalsInto.reduce((sum, award) => sum + award.points, 0) + addPlayer(board, kill.shooterId).medals += medalsInto.length - firstOfKill + const bonus = medalPointsFrom(medalsInto, firstOfKill) return applyKill(board, kill, config.match.pointsPerKill) + bonus } diff --git a/packages/core/src/weapon/weaponState.test.ts b/packages/core/src/weapon/weaponState.test.ts index 8e8c9df6..119d5732 100644 --- a/packages/core/src/weapon/weaponState.test.ts +++ b/packages/core/src/weapon/weaponState.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { IDLE_WEAPON_INPUT } from './weaponInput.ts' import { canFire, closeScope, @@ -9,7 +10,7 @@ import { rearmWeapon, weaponPhase, } from './weaponState.ts' -import { armedWeapon, shippedConfig } from './weaponTestKit.ts' +import { armedWeapon, FIRE, runTicks, SCOPE, shippedConfig } from './weaponTestKit.ts' const config = shippedConfig() @@ -156,6 +157,20 @@ describe('rearmWeapon', () => { const weapon = { ...createWeaponState(config), shotsFired: 17 } expect(rearmWeapon(weapon, config).shotsFired).toBe(17) }) + + it('o gatilho segurado pela morte não dispara no primeiro tick: pede um aperto novo', () => { + const weapon = rearmWeapon(armedWeapon(config), config) + runTicks(weapon, FIRE, 1, config) + expect(weapon.shotsFired).toBe(0) + runTicks(weapon, IDLE_WEAPON_INPUT, 1, config) + runTicks(weapon, FIRE, 1, config) + expect(weapon.shotsFired).toBe(1) + }) + + it('a mira de interruptor segurada pela morte não abre sozinha no respawn', () => { + const weapon = rearmWeapon(armedWeapon(config), config) + expect(runTicks(weapon, SCOPE, 1, config).scoped).toBe(false) + }) }) describe('closeScope', () => { diff --git a/packages/core/src/weapon/weaponState.ts b/packages/core/src/weapon/weaponState.ts index 3dceb574..576badad 100644 --- a/packages/core/src/weapon/weaponState.ts +++ b/packages/core/src/weapon/weaponState.ts @@ -102,6 +102,14 @@ export function copyWeaponState(from: Readonly, into: WeaponState): * O `shotsFired` **sobrevive**: é a identidade do tiro, e recomeçá-lo * repetiria a semente de dispersão de tiros que já aconteceram. * + * Os botões de borda voltam **presos**, como as teclas de `rearmMelee` e + * `respawnCharacter`: o gatilho segurado pela morte, pelo respawn ou pela + * revanche disparava no primeiro tick, porque a arma nova achava que ele + * acabara de descer (revisão do PR #31). Presos, e não copiados: a arma não + * anda durante a morte, então o valor antigo não sabe do aperto feito na tela + * de morte. A mira de segurar fica de fora — ela abre por estar presa, não + * pela borda, e presa de mentira seguraria a corrida no tick do respawn. + * * ```ts * rearmWeapon(session.weapon, config) * ``` @@ -110,6 +118,9 @@ export function rearmWeapon(state: WeaponState, config: GameplayConfig): WeaponS const shotsFired = state.shotsFired copyWeaponState(createWeaponState(config), state) state.shotsFired = shotsFired + state.fireWasHeld = true + state.scopeWasHeld = true + state.reloadWasHeld = true return state }