From c560e846698ee99429be2af4a0e72a79198371d9 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Sun, 14 Jun 2026 16:11:44 -0300 Subject: [PATCH] Remove committed local test folder --- test/combatBudget.test.mjs | 150 -------- test/damageContext.test.mjs | 84 ----- test/enemyAcquisition.test.mjs | 366 ------------------ test/enemyCombat.test.mjs | 510 -------------------------- test/enemyProjectiles.test.mjs | 220 ----------- test/playerDeath.test.mjs | 141 ------- test/renderBundlePreloadUrls.test.mjs | 46 --- 7 files changed, 1517 deletions(-) delete mode 100644 test/combatBudget.test.mjs delete mode 100644 test/damageContext.test.mjs delete mode 100644 test/enemyAcquisition.test.mjs delete mode 100644 test/enemyCombat.test.mjs delete mode 100644 test/enemyProjectiles.test.mjs delete mode 100644 test/playerDeath.test.mjs delete mode 100644 test/renderBundlePreloadUrls.test.mjs diff --git a/test/combatBudget.test.mjs b/test/combatBudget.test.mjs deleted file mode 100644 index 402b2d3..0000000 --- a/test/combatBudget.test.mjs +++ /dev/null @@ -1,150 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { importTsModule } from "./importTsModule.mjs"; - -const { - QUAKE_COMBAT_BUDGET_LIMITS, - createQuakeCombatBudgetRuntime, - quakeCombatLogicalWeaponTargetIndexes, -} = await importTsModule("src/runtime/shootables/combatBudget.ts"); - -test("mounted enemy acquisition switch defaults on and stays independent from broad combat flags", () => { - const budget = createQuakeCombatBudgetRuntime(); - - assert.equal(budget.mountedEnemyAcquisitionEnabled(), true); - assert.equal(budget.expandedLogicalCombatEnabled(), false); - assert.equal(budget.debugStats().mountedEnemyAcquisitionEnabled, true); - - budget.setMountedEnemyAcquisitionEnabled(false); - assert.equal(budget.mountedEnemyAcquisitionEnabled(), false); - assert.equal(budget.expandedLogicalCombatEnabled(), false); - assert.equal(budget.debugStats().mountedEnemyAcquisitionEnabled, false); - assert.equal(budget.debugStats().counters.disableSwitchActivationsTotal, 1); - - budget.setExpandedLogicalCombatEnabled(true); - assert.equal(budget.expandedLogicalCombatEnabled(), true); - assert.equal(budget.mountedEnemyAcquisitionEnabled(), false); - - budget.reset(); - assert.equal(budget.expandedLogicalCombatEnabled(), false); - assert.equal(budget.mountedEnemyAcquisitionEnabled(), true); - assert.equal(budget.debugStats().mountedEnemyAcquisitionEnabled, true); -}); - -test("combat budget exposes the initial fast-path caps", () => { - assert.deepEqual(QUAKE_COMBAT_BUDGET_LIMITS, { - ambientPathCadenceHz: 5, - ambientPathTicksPerFrame: 1, - ambientPathTicksPerSecond: 30, - attackChainChecksPerFrame: 8, - combatInterestSet: 12, - domReads: 0, - lineOfSightChecksPerFrame: 8, - lineOfSightChecksPerSecond: 200, - unmountedAiActiveSet: 4, - unmountedAiCadenceHz: 5, - }); -}); - -test("ambient path scheduler is frame-capped and per-entity cadenced", () => { - const budget = createQuakeCombatBudgetRuntime(); - - assert.deepEqual(budget.tryStartAmbientPathTick(1, 1000), { accepted: true, reason: "accepted" }); - assert.deepEqual(budget.tryStartAmbientPathTick(2, 1000), { accepted: false, reason: "frame-cap" }); - budget.beginFrame(1100); - assert.deepEqual(budget.tryStartAmbientPathTick(1, 1100), { accepted: false, reason: "cadence" }); - assert.deepEqual(budget.tryStartAmbientPathTick(2, 1100), { accepted: true, reason: "accepted" }); - budget.beginFrame(1200); - assert.deepEqual(budget.tryStartAmbientPathTick(1, 1200), { accepted: true, reason: "accepted" }); - - const stats = budget.debugStats(); - assert.equal(stats.counters.ambientPathTicksTotal, 3); - assert.equal(stats.counters.ambientPathTickDeferralsTotal, 2); - assert.equal(stats.maxFrame.ambientPathTicks, QUAKE_COMBAT_BUDGET_LIMITS.ambientPathTicksPerFrame); -}); - -test("ambient path scheduler enforces a global per-second cap", () => { - const budget = createQuakeCombatBudgetRuntime(); - - for (let entityIndex = 1; entityIndex <= QUAKE_COMBAT_BUDGET_LIMITS.ambientPathTicksPerSecond; entityIndex += 1) { - budget.beginFrame(1000 + entityIndex); - assert.deepEqual(budget.tryStartAmbientPathTick(entityIndex, 1000 + entityIndex), { - accepted: true, - reason: "accepted", - }); - } - budget.beginFrame(1100); - assert.deepEqual(budget.tryStartAmbientPathTick(999, 1100), { accepted: false, reason: "second-cap" }); -}); - -test("logical weapon target indexes include mounted-visible or combat-interested live targets only", () => { - const indexes = quakeCombatLogicalWeaponTargetIndexes([ - { combatInterested: false, entityIndex: 1, inLineOfFire: true, live: true, mounted: true, visible: true }, - { combatInterested: true, entityIndex: 2, inLineOfFire: true, live: true, mounted: false, visible: false }, - { combatInterested: true, entityIndex: 3, inLineOfFire: false, live: true, mounted: false, visible: false }, - { combatInterested: true, entityIndex: 4, inLineOfFire: true, live: false, mounted: false, visible: false }, - { combatInterested: false, entityIndex: 5, inLineOfFire: true, live: true, mounted: false, visible: false }, - ]); - - assert.deepEqual(indexes, [1, 2]); -}); - -test("combat interest set is capped and evicts oldest entries", () => { - const budget = createQuakeCombatBudgetRuntime(); - budget.setExpandedLogicalCombatEnabled(true); - - for (let entityIndex = 1; entityIndex <= QUAKE_COMBAT_BUDGET_LIMITS.combatInterestSet + 2; entityIndex += 1) { - const result = budget.recordCombatInterest(entityIndex, entityIndex * 10); - assert.equal(result.accepted, true); - } - - const stats = budget.debugStats(); - assert.equal(stats.combatInterestSetSize, QUAKE_COMBAT_BUDGET_LIMITS.combatInterestSet); - assert.equal(stats.counters.combatInterestAddsTotal, QUAKE_COMBAT_BUDGET_LIMITS.combatInterestSet + 2); - assert.equal(stats.counters.combatInterestEvictionsTotal, 2); - assert.equal(budget.hasCombatInterest(1), false); - assert.equal(budget.hasCombatInterest(2), false); - assert.equal(budget.hasCombatInterest(3), true); -}); - -test("unmounted AI scheduler is disabled by default and requires combat interest", () => { - const budget = createQuakeCombatBudgetRuntime(); - - assert.deepEqual(budget.tryStartUnmountedAiTick(1, 0), { accepted: false, reason: "disabled" }); - budget.setUnmountedAiEnabled(true); - assert.deepEqual(budget.tryStartUnmountedAiTick(1, 100), { accepted: false, reason: "disabled" }); - budget.setExpandedLogicalCombatEnabled(true); - assert.deepEqual(budget.tryStartUnmountedAiTick(1, 200), { accepted: false, reason: "disabled" }); - budget.setUnmountedAiEnabled(true); - assert.deepEqual(budget.tryStartUnmountedAiTick(1, 300), { accepted: false, reason: "not-interested" }); - - budget.recordCombatInterest(1, 300); - assert.deepEqual(budget.tryStartUnmountedAiTick(1, 400), { accepted: true, reason: "accepted" }); - budget.completeUnmountedAiTick(1); -}); - -test("unmounted AI scheduler enforces cadence and active-set cap", () => { - const budget = createQuakeCombatBudgetRuntime(); - budget.setExpandedLogicalCombatEnabled(true); - budget.setUnmountedAiEnabled(true); - - for (let entityIndex = 1; entityIndex <= 5; entityIndex += 1) { - budget.recordCombatInterest(entityIndex, 0); - } - - assert.deepEqual(budget.tryStartUnmountedAiTick(1, 1000), { accepted: true, reason: "accepted" }); - budget.completeUnmountedAiTick(1); - assert.deepEqual(budget.tryStartUnmountedAiTick(1, 1100), { accepted: false, reason: "cadence" }); - assert.deepEqual(budget.tryStartUnmountedAiTick(1, 1200), { accepted: true, reason: "accepted" }); - - for (let entityIndex = 2; entityIndex <= 4; entityIndex += 1) { - assert.deepEqual(budget.tryStartUnmountedAiTick(entityIndex, 1200), { accepted: true, reason: "accepted" }); - } - assert.deepEqual(budget.tryStartUnmountedAiTick(5, 1200), { accepted: false, reason: "capacity" }); - - const stats = budget.debugStats(); - assert.equal(stats.unmountedAiActiveSetSize, QUAKE_COMBAT_BUDGET_LIMITS.unmountedAiActiveSet); - assert.equal(stats.counters.unmountedAiTicksTotal, 5); - assert.equal(stats.counters.capDeferralsTotal, 1); -}); diff --git a/test/damageContext.test.mjs b/test/damageContext.test.mjs deleted file mode 100644 index 3cd851d..0000000 --- a/test/damageContext.test.mjs +++ /dev/null @@ -1,84 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { importTsModule } from "./importTsModule.mjs"; - -const { - quakeDamageRetargetDecision, -} = await importTsModule("src/runtime/shootables/damage.ts"); - -const player = { classname: "player", id: "player", kind: "player" }; -const world = { classname: "world", id: "world", kind: "world" }; - -function monsterActor(classname, entityIndex) { - return { classname, entityIndex, id: entityIndex, kind: "shootable" }; -} - -function monsterTarget(classname, entityIndex = 10) { - return { classname, entityIndex, monster: true }; -} - -test("Quake damage retargets monsters to a different-class attacker", () => { - const decision = quakeDamageRetargetDecision({ - attacker: monsterActor("monster_dog", 20), - currentEnemy: player, - target: monsterTarget("monster_ogre"), - }); - - assert.equal(decision.retarget, true); - assert.equal(decision.reason, "retarget"); - assert.equal(decision.preserveOldEnemy, true); - assert.deepEqual(decision.target, { - classname: "monster_dog", - entityIndex: 20, - id: 20, - kind: "shootable", - }); -}); - -test("Quake damage keeps same-class monsters calm except soldiers", () => { - const sameOgre = quakeDamageRetargetDecision({ - attacker: monsterActor("monster_ogre", 20), - currentEnemy: player, - target: monsterTarget("monster_ogre"), - }); - const sameSoldier = quakeDamageRetargetDecision({ - attacker: monsterActor("monster_army", 21), - currentEnemy: player, - target: monsterTarget("monster_army"), - }); - - assert.equal(sameOgre.retarget, false); - assert.equal(sameOgre.reason, "same-class"); - assert.equal(sameSoldier.retarget, true); - assert.equal(sameSoldier.reason, "retarget"); - assert.equal(sameSoldier.target.entityIndex, 21); -}); - -test("Quake damage does not retarget to world, self, or current enemy", () => { - const cases = [ - { - expected: "world", - input: { attacker: world, target: monsterTarget("monster_army") }, - }, - { - expected: "attacker-is-self", - input: { attacker: monsterActor("monster_dog", 10), target: monsterTarget("monster_dog", 10) }, - }, - { - expected: "attacker-is-current-enemy", - input: { - attacker: monsterActor("monster_dog", 20), - currentEnemy: { classname: "monster_dog", entityIndex: 20, id: 20, kind: "shootable" }, - target: monsterTarget("monster_ogre"), - }, - }, - ]; - - for (const entry of cases) { - const decision = quakeDamageRetargetDecision(entry.input); - assert.equal(decision.retarget, false); - assert.equal(decision.reason, entry.expected); - assert.equal(decision.target, null); - } -}); diff --git a/test/enemyAcquisition.test.mjs b/test/enemyAcquisition.test.mjs deleted file mode 100644 index ce42bb6..0000000 --- a/test/enemyAcquisition.test.mjs +++ /dev/null @@ -1,366 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { importTsModule } from "./importTsModule.mjs"; - -const { - createQuakeEnemyAcquisitionVisibilityCache, - quakeEnemyAcquisitionInFront, - quakeEnemyAcquisitionRangeFromSourceUnits, - quakeEnemyFindTarget, -} = await importTsModule("src/runtime/shootables/enemyAcquisition.ts"); - -const FL_NOTARGET = 128; -const IT_INVISIBILITY = 524_288; - -function monster(overrides = {}) { - return { - classname: "monster_army", - id: "monster", - origin: [0, 0, 0], - spawnflags: 0, - viewOffset: [0, 0, 0], - yaw: 0, - ...overrides, - }; -} - -function player(overrides = {}) { - return { - classname: "player", - health: 100, - id: "player", - inPvs: true, - origin: [240, 0, 0], - showHostileUntilSeconds: 0, - viewOffset: [0, 0, 0], - ...overrides, - }; -} - -function acquisition(overrides = {}) { - let losCalls = 0; - let budgetCalls = 0; - const losResult = overrides.losResult ?? true; - const budgetResult = overrides.budgetResult ?? true; - const result = quakeEnemyFindTarget({ - checkClient: overrides.checkClient === undefined ? player(overrides.player ?? {}) : overrides.checkClient, - hasLineOfSight: (...args) => { - losCalls += 1; - return typeof losResult === "function" ? losResult(...args) : losResult; - }, - monster: monster(overrides.monster ?? {}), - nowSeconds: overrides.nowSeconds ?? 10, - sightEntity: overrides.sightEntity, - sourceUnitScale: overrides.sourceUnitScale, - trySpendLineOfSightCheck: overrides.withoutBudget - ? undefined - : () => { - budgetCalls += 1; - return typeof budgetResult === "function" ? budgetResult() : budgetResult; - }, - visibilityCache: overrides.visibilityCache, - }); - return { budgetCalls, losCalls, result }; -} - -test("source range thresholds match QuakeC range()", () => { - assert.equal(quakeEnemyAcquisitionRangeFromSourceUnits(119.99), "melee"); - assert.equal(quakeEnemyAcquisitionRangeFromSourceUnits(120), "near"); - assert.equal(quakeEnemyAcquisitionRangeFromSourceUnits(499.99), "near"); - assert.equal(quakeEnemyAcquisitionRangeFromSourceUnits(500), "mid"); - assert.equal(quakeEnemyAcquisitionRangeFromSourceUnits(999.99), "mid"); - assert.equal(quakeEnemyAcquisitionRangeFromSourceUnits(1000), "far"); -}); - -test("runtime-scaled callers can pass an explicit source unit scale", () => { - const sourceUnitScale = 0.02; - const near = acquisition({ - player: { origin: [240 * sourceUnitScale, 0, 0] }, - sourceUnitScale, - }); - const far = acquisition({ - player: { origin: [1200 * sourceUnitScale, 0, 0] }, - sourceUnitScale, - }); - - assert.equal(near.result.acquired, true); - assert.equal(near.result.range, "near"); - assert.equal(far.result.acquired, false); - assert.equal(far.result.range, "far"); - assert.equal(far.result.reason, "far"); - assert.equal(far.losCalls, 0); -}); - -test("source infront gate uses a yaw forward dot greater than 0.3", () => { - assert.equal(quakeEnemyAcquisitionInFront(monster({ yaw: 0 }), player({ origin: [240, 0, 0] })), true); - assert.equal(quakeEnemyAcquisitionInFront(monster({ yaw: 0 }), player({ origin: [-240, 0, 0] })), false); - assert.equal(quakeEnemyAcquisitionInFront(monster({ yaw: 90 }), player({ origin: [0, 240, 0] })), true); - assert.equal(quakeEnemyAcquisitionInFront(monster({ yaw: 90 }), player({ origin: [240, 0, 0] })), false); -}); - -test("acquisition rejects non-viable clients before spending LOS", () => { - const cases = [ - { - name: "dead target", - player: { health: 0 }, - reason: "dead-target", - }, - { - name: "FL_NOTARGET", - player: { flags: FL_NOTARGET }, - reason: "notarget", - }, - { - name: "notarget boolean", - player: { notarget: true }, - reason: "notarget", - }, - { - name: "IT_INVISIBILITY", - player: { items: IT_INVISIBILITY }, - reason: "invisibility", - }, - { - name: "invisible boolean", - player: { invisible: true }, - reason: "invisibility", - }, - { - name: "far target", - player: { origin: [1200, 0, 0] }, - reason: "far", - }, - { - name: "near behind and not hostile", - player: { origin: [-240, 0, 0], showHostileUntilSeconds: 0 }, - reason: "behind-near", - }, - { - name: "mid behind even when hostile", - player: { origin: [-700, 0, 0], showHostileUntilSeconds: 20 }, - reason: "behind-mid", - }, - ]; - - for (const entry of cases) { - const { budgetCalls, losCalls, result } = acquisition({ player: entry.player }); - assert.equal(result.acquired, false, entry.name); - assert.equal(result.reason, entry.reason, entry.name); - assert.equal(result.lineOfSight, "not-needed", entry.name); - assert.equal(budgetCalls, 0, entry.name); - assert.equal(losCalls, 0, entry.name); - } -}); - -test("acquisition rejects a missing or non-PVS checkclient without LOS", () => { - for (const checkClient of [null, player({ inPvs: false })]) { - const { budgetCalls, losCalls, result } = acquisition({ checkClient }); - assert.equal(result.acquired, false); - assert.equal(result.reason, "no-client"); - assert.equal(result.candidateId, null); - assert.equal(budgetCalls, 0); - assert.equal(losCalls, 0); - } -}); - -test("melee target can be acquired from behind but still needs visibility", () => { - const { budgetCalls, losCalls, result } = acquisition({ - player: { origin: [-80, 0, 0], showHostileUntilSeconds: 0 }, - }); - - assert.equal(result.acquired, true); - assert.equal(result.reason, "acquired"); - assert.equal(result.range, "melee"); - assert.equal(result.inFront, null); - assert.equal(result.visible, true); - assert.equal(result.lineOfSight, "computed"); - assert.equal(budgetCalls, 1); - assert.equal(losCalls, 1); -}); - -test("near target behind the monster can be acquired while show_hostile is active", () => { - const { budgetCalls, losCalls, result } = acquisition({ - player: { origin: [-240, 0, 0], showHostileUntilSeconds: 20 }, - }); - - assert.equal(result.acquired, true); - assert.equal(result.reason, "acquired"); - assert.equal(result.range, "near"); - assert.equal(result.inFront, null); - assert.equal(result.visible, true); - assert.equal(budgetCalls, 1); - assert.equal(losCalls, 1); -}); - -test("mid target behind the monster is rejected even while show_hostile is active", () => { - const { budgetCalls, losCalls, result } = acquisition({ - player: { origin: [-700, 0, 0], showHostileUntilSeconds: 20 }, - }); - - assert.equal(result.acquired, false); - assert.equal(result.reason, "behind-mid"); - assert.equal(result.range, "mid"); - assert.equal(result.inFront, false); - assert.equal(budgetCalls, 0); - assert.equal(losCalls, 0); -}); - -test("visible candidate acquires and blocked LOS rejects after the budgeted trace", () => { - const hit = acquisition({ player: { origin: [240, 0, 0] } }); - assert.equal(hit.result.acquired, true); - assert.equal(hit.result.reason, "acquired"); - assert.equal(hit.result.range, "near"); - assert.equal(hit.result.inFront, true); - assert.equal(hit.result.lineOfSight, "computed"); - assert.equal(hit.result.targetId, "player"); - assert.equal(hit.budgetCalls, 1); - assert.equal(hit.losCalls, 1); - - const blocked = acquisition({ losResult: false, player: { origin: [240, 0, 0] } }); - assert.equal(blocked.result.acquired, false); - assert.equal(blocked.result.reason, "not-visible"); - assert.equal(blocked.result.visible, false); - assert.equal(blocked.result.lineOfSight, "computed"); - assert.equal(blocked.budgetCalls, 1); - assert.equal(blocked.losCalls, 1); -}); - -test("LOS budget denial defers acquisition without calling the trace", () => { - const { budgetCalls, losCalls, result } = acquisition({ - budgetResult: false, - player: { origin: [240, 0, 0] }, - }); - - assert.equal(result.acquired, false); - assert.equal(result.deferred, true); - assert.equal(result.reason, "los-budget"); - assert.equal(result.lineOfSight, "budget-denied"); - assert.equal(result.visible, null); - assert.equal(budgetCalls, 1); - assert.equal(losCalls, 0); -}); - -test("visibility cache reuses recent LOS and expires by TTL", () => { - const cache = createQuakeEnemyAcquisitionVisibilityCache({ ttlSeconds: 0.25 }); - const first = acquisition({ - nowSeconds: 10, - player: { origin: [240, 0, 0] }, - visibilityCache: cache, - }); - assert.equal(first.result.acquired, true); - assert.equal(first.result.lineOfSight, "computed"); - assert.equal(first.budgetCalls, 1); - assert.equal(first.losCalls, 1); - assert.equal(cache.size(), 1); - - const cached = acquisition({ - budgetResult: false, - nowSeconds: 10.1, - player: { origin: [240, 0, 0] }, - visibilityCache: cache, - }); - assert.equal(cached.result.acquired, true); - assert.equal(cached.result.lineOfSight, "cached"); - assert.equal(cached.budgetCalls, 0); - assert.equal(cached.losCalls, 0); - - const expired = acquisition({ - budgetResult: false, - nowSeconds: 10.5, - player: { origin: [240, 0, 0] }, - visibilityCache: cache, - }); - assert.equal(expired.result.acquired, false); - assert.equal(expired.result.reason, "los-budget"); - assert.equal(expired.budgetCalls, 1); - assert.equal(expired.losCalls, 0); -}); - -test("visibility cache key includes positions so moved targets recompute LOS", () => { - const cache = createQuakeEnemyAcquisitionVisibilityCache(); - const first = acquisition({ - nowSeconds: 10, - player: { origin: [240, 0, 0] }, - visibilityCache: cache, - }); - const moved = acquisition({ - nowSeconds: 10.05, - player: { origin: [260, 0, 0] }, - visibilityCache: cache, - }); - - assert.equal(first.result.lineOfSight, "computed"); - assert.equal(moved.result.lineOfSight, "computed"); - assert.equal(moved.budgetCalls, 1); - assert.equal(moved.losCalls, 1); -}); - -test("recent sight_entity can stand in for checkclient unless ambush flags block it", () => { - const sightPlayer = player({ id: "sight-player", origin: [400, 0, 0] }); - const sightMonster = player({ - classname: "monster_army", - enemy: sightPlayer, - id: "sight-monster", - origin: [240, 0, 0], - }); - const open = acquisition({ - checkClient: null, - sightEntity: { entity: sightMonster, seenAtSeconds: 9.95 }, - }); - assert.equal(open.result.acquired, true); - assert.equal(open.result.usedSightEntity, true); - assert.equal(open.result.candidateId, "sight-monster"); - assert.equal(open.result.targetId, "sight-player"); - assert.equal(open.budgetCalls, 1); - assert.equal(open.losCalls, 1); - - for (const spawnflags of [1, 2, 3]) { - const blocked = acquisition({ - checkClient: null, - monster: { spawnflags }, - sightEntity: { entity: sightMonster, seenAtSeconds: 9.95 }, - }); - assert.equal(blocked.result.acquired, false, `spawnflags ${spawnflags}`); - assert.equal(blocked.result.reason, "no-client", `spawnflags ${spawnflags}`); - assert.equal(blocked.result.usedSightEntity, false, `spawnflags ${spawnflags}`); - assert.equal(blocked.budgetCalls, 0, `spawnflags ${spawnflags}`); - assert.equal(blocked.losCalls, 0, `spawnflags ${spawnflags}`); - } -}); - -test("sight_entity is ignored when it already points at the current enemy", () => { - const sightPlayer = player({ id: "player-current", origin: [400, 0, 0] }); - const sightMonster = player({ - classname: "monster_army", - enemy: sightPlayer, - id: "sight-monster", - origin: [240, 0, 0], - }); - const { budgetCalls, losCalls, result } = acquisition({ - checkClient: null, - monster: { currentEnemyId: "player-current" }, - sightEntity: { entity: sightMonster, seenAtSeconds: 9.95 }, - }); - - assert.equal(result.acquired, false); - assert.equal(result.reason, "same-enemy"); - assert.equal(result.usedSightEntity, true); - assert.equal(budgetCalls, 0); - assert.equal(losCalls, 0); -}); - -test("non-player proxy without a player enemy fails before LOS", () => { - const proxy = player({ - classname: "monster_army", - enemy: player({ classname: "monster_dog", id: "dog" }), - id: "proxy", - origin: [240, 0, 0], - }); - const { budgetCalls, losCalls, result } = acquisition({ checkClient: proxy }); - - assert.equal(result.acquired, false); - assert.equal(result.reason, "non-player-proxy"); - assert.equal(budgetCalls, 0); - assert.equal(losCalls, 0); -}); diff --git a/test/enemyCombat.test.mjs b/test/enemyCombat.test.mjs deleted file mode 100644 index ecf90cc..0000000 --- a/test/enemyCombat.test.mjs +++ /dev/null @@ -1,510 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { importTsModule } from "./importTsModule.mjs"; - -const { - createQuakeEnemyCombatRuntime, - quakeEnemyWakeDelayMs, - quakeShootableAttackHasBranchSightCheck, - quakeShootableAttackUsesCanDamage, - selectQuakeEnemyAttackChain, -} = await importTsModule("src/runtime/shootables/enemyCombat.ts"); -const { - QUAKE_MONSTER_HUNT_TARGET_ATTACK_DELAY_MS, - quakeMonsterCombatProfile, - quakeMonsterSightSoundPath, -} = await importTsModule("src/runtime/shootables/combatFacts.ts"); -const { - createQuakeRandomStream, - createEnemyState, -} = await importTsModule("src/runtime/shootables/enemyStateFactory.ts"); -const { - QUAKE_COLLISION_UNIT_SCALE, -} = await importTsModule("src/runtime/constants.ts"); - -function createCombatRuntime(options = {}) { - const sounds = []; - const traces = []; - const runtime = createQuakeEnemyCombatRuntime({ - damagePlayer: options.damagePlayer ?? (() => true), - getPlayerOrigin: () => [0, 0, 0], - hasLineOfSight: () => true, - markTrace: (kind, shootable, details) => { - traces.push({ kind, entityIndex: shootable.entity.index, details }); - }, - nextRandom: options.nextRandom ?? (() => 0), - playerDamageBounds: options.playerDamageBounds ?? ((origin) => ({ - min: [origin[0] - 1, origin[1] - 1, origin[2] - 1], - max: [origin[0] + 1, origin[1] + 1, origin[2] + 1], - })), - playSound: (soundPath, options) => { - sounds.push({ soundPath, options }); - return true; - }, - randomRange: (_enemy, min, max) => (min + max) * 0.5, - shootableBoundsForDamage: () => ({ - min: [-1, -1, -1], - max: [1, 1, 1], - }), - shootableEyeOrigin: (shootable) => shootable.origin, - spawnProjectile: () => undefined, - syncEnemyDatasets: () => undefined, - }); - return { runtime, sounds, traces }; -} - -test("QuakeC random stream follows source seed and is shared across enemies", () => { - const stream = createQuakeRandomStream(12345); - const enemyA = createEnemyState(1, {}, null, 0); - const enemyB = createEnemyState(2, {}, null, 0); - const nextForEnemy = (_enemy) => stream.next(); - - assert.equal(roundRandom(nextForEnemy(enemyA)), 0.845596313); - assert.equal(roundRandom(nextForEnemy(enemyB)), 0.239395142); - assert.equal(roundRandom(nextForEnemy(enemyA)), 0.85295105); - assert.ok(Math.abs(stream.range(1000, 3000) - 2744.720459) < 0.000001); -}); - -test("wizard attack start plays the QuakeC Wiz_StartFast sound", () => { - const { runtime, sounds, traces } = createCombatRuntime(); - const shootable = { - entity: { index: 7, classname: "monster_wizard" }, - origin: [0, 0, 0], - }; - - runtime.runFrameSounds( - shootable, - { - calls: ["ai_face", "Wiz_StartFast"], - chain: "attack", - chainCycleEnd: false, - classname: "monster_wizard", - events: [], - frame: "magatt1", - frameIndex: 29, - movement: [], - next: "wiz_fast2", - sounds: [], - stateName: "wiz_fast1", - }, - "attack", - 1234, - ); - - assert.deepEqual(sounds.map((sound) => sound.soundPath), ["wizard/wattack.wav"]); - assert.equal(traces[0]?.kind, "enemy-quakec-sound"); - assert.equal(traces[0]?.details.sound, "wizard/wattack.wav"); -}); - -test("conditional QuakeC frame sounds consume shared random and only play on chance hit", () => { - const rolls = [0.21, 0.19]; - const { runtime, sounds, traces } = createCombatRuntime({ - nextRandom: () => rolls.shift(), - }); - const shootable = { - enemy: createEnemyState(9, {}, null, 0), - entity: { index: 9, classname: "monster_ogre" }, - origin: [0, 0, 0], - }; - const step = { - calls: ["ai_run", "sound"], - chain: "run", - chainCycleEnd: false, - classname: "monster_ogre", - conditionalSounds: [{ chance: 0.2, soundPath: "ogre/ogidle2.wav" }], - events: [], - frame: "run1", - frameIndex: 25, - movement: [], - next: "ogre_run2", - sounds: [], - stateName: "ogre_run1", - }; - - runtime.runFrameSounds(shootable, step, "walk", 1000); - runtime.runFrameSounds(shootable, step, "walk", 1100); - - assert.deepEqual(sounds.map((sound) => sound.soundPath), ["ogre/ogidle2.wav"]); - assert.deepEqual( - traces - .filter((trace) => trace.kind === "enemy-quakec-conditional-sound") - .map((trace) => ({ - chance: trace.details.chance, - played: trace.details.played, - roll: trace.details.roll, - sound: trace.details.sound, - state: trace.details.state, - })), - [ - { - chance: 0.2, - played: false, - roll: 0.21, - sound: "ogre/ogidle2.wav", - state: "ogre_run1", - }, - { - chance: 0.2, - played: true, - roll: 0.19, - sound: "ogre/ogidle2.wav", - state: "ogre_run1", - }, - ], - ); -}); - -function roundRandom(value) { - return Math.round(value * 1_000_000_000) / 1_000_000_000; -} - -test("monster wake delay follows source attack_finished usage", () => { - const { runtime } = createCombatRuntime(); - const delayedMonsters = [ - "monster_army", - "monster_demon1", - "monster_knight", - "monster_ogre", - "monster_shambler", - "monster_wizard", - "monster_zombie", - ]; - - for (const classname of delayedMonsters) { - const profile = quakeMonsterCombatProfile(classname); - const enemy = createEnemyState(100, {}, null, 0); - assert.equal(profile?.wakeDelayMs, QUAKE_MONSTER_HUNT_TARGET_ATTACK_DELAY_MS, classname); - assert.equal(quakeEnemyWakeDelayMs(runtime, profile, enemy), QUAKE_MONSTER_HUNT_TARGET_ATTACK_DELAY_MS, classname); - } - - assert.equal(quakeMonsterCombatProfile("monster_dog")?.wakeDelayMs, 0); - assert.equal(quakeEnemyWakeDelayMs(runtime, quakeMonsterCombatProfile("monster_dog"), createEnemyState(100, {}, null, 0)), 0); - assert.equal(quakeMonsterCombatProfile("monster_boss")?.wakeDelayMs, 0); -}); - -test("monster sight sounds follow QuakeC SightSound mapping", () => { - assert.deepEqual( - Object.fromEntries([ - "monster_army", - "monster_dog", - "monster_demon1", - "monster_knight", - "monster_ogre", - "monster_shambler", - "monster_wizard", - "monster_zombie", - ].map((classname) => [classname, quakeMonsterSightSoundPath(classname)])), - { - monster_army: "soldier/sight1.wav", - monster_demon1: "demon/sight2.wav", - monster_dog: "dog/dsight.wav", - monster_knight: "knight/ksight.wav", - monster_ogre: "ogre/ogwake.wav", - monster_shambler: "shambler/ssight.wav", - monster_wizard: "wizard/wsight.wav", - monster_zombie: "zombie/z_idle.wav", - }, - ); - assert.equal(quakeMonsterSightSoundPath("monster_boss"), null); -}); - -test("ogre missile branch starts QuakeC attack cooldown on selection", () => { - const { runtime, traces } = createCombatRuntime(); - const enemy = createEnemyState(11, {}, null, 0); - const shootable = { - collisionBounds: { - min: [-16, -16, -24], - max: [16, 16, 40], - }, - enemy, - entity: { index: 11, classname: "monster_ogre" }, - origin: [0, 0, 0], - }; - const now = 5000; - const chain = selectQuakeEnemyAttackChain( - { - ...runtime, - nextRandom: () => 0.99, - randomRange: () => 2000, - }, - shootable, - enemy, - 300 * QUAKE_COLLISION_UNIT_SCALE, - [300 * QUAKE_COLLISION_UNIT_SCALE, 0, 0], - now, - ); - - assert.equal(chain, "missile"); - assert.equal(enemy.nextAttackAt, now + 3000); - assert.equal(quakeShootableAttackHasBranchSightCheck(shootable), true); - assert.deepEqual( - traces - .filter((trace) => trace.kind === "enemy-quakec-attack-cooldown" || trace.kind === "enemy-quakec-attack-select") - .map((trace) => ({ - branchKind: trace.details.branchKind, - chain: trace.details.chain, - cooldownMs: trace.details.cooldownMs, - kind: trace.kind, - randomMs: trace.details.randomMs, - })), - [ - { - branchKind: "missile", - chain: "missile", - cooldownMs: 3000, - kind: "enemy-quakec-attack-cooldown", - randomMs: 2000, - }, - { - branchKind: "missile", - chain: "missile", - cooldownMs: 3000, - kind: "enemy-quakec-attack-select", - randomMs: undefined, - }, - ], - ); - - enemy.pendingAttack = { - fireAt: Infinity, - quakecChain: "missile", - target: [300 * QUAKE_COLLISION_UNIT_SCALE, 0, 0], - }; - runtime.finishAttack( - shootable, - { - cooldownMs: 1000, - cooldownRandomAddMs: 2000, - damage: 40, - kind: "projectile", - range: 1000 * QUAKE_COLLISION_UNIT_SCALE, - }, - now + 700, - ); - - assert.equal(enemy.nextAttackAt, now + 3000); -}); - -test("ogre melee branch uses QuakeC CanDamage offset traces", () => { - const { runtime } = createCombatRuntime(); - const enemy = createEnemyState(12, {}, null, 0); - const shootable = { - collisionBounds: { - min: [-16, -16, -24], - max: [16, 16, 40], - }, - enemy, - entity: { index: 12, classname: "monster_ogre" }, - origin: [0, 0, 0], - }; - const traceEnds = []; - const target = [80 * QUAKE_COLLISION_UNIT_SCALE, 0, 0]; - const chain = selectQuakeEnemyAttackChain( - { - ...runtime, - hasLineOfSight: (_start, end) => { - traceEnds.push(end); - return Math.abs(end[1]) > 0; - }, - nextRandom: () => 0.99, - randomRange: () => 2000, - }, - shootable, - enemy, - 80 * QUAKE_COLLISION_UNIT_SCALE, - target, - 5000, - ); - - assert.equal(quakeShootableAttackUsesCanDamage(shootable), true); - assert.equal(quakeShootableAttackHasBranchSightCheck(shootable), true); - assert.equal(chain, "melee"); - assert.equal(traceEnds[0][0], target[0]); - assert.equal(traceEnds[0][1], target[1]); - assert.ok(traceEnds.some((end) => Math.abs(end[1]) === 15 * QUAKE_COLLISION_UNIT_SCALE)); -}); - -test("zombie missile attack selects source random attack variants before cooldown", () => { - const { runtime } = createCombatRuntime(); - const cases = [ - { roll: 0.2, chain: "attack" }, - { roll: 0.45, chain: "attack_b" }, - { roll: 0.8, chain: "attack_c" }, - ]; - - for (const testCase of cases) { - const enemy = createEnemyState(20, {}, null, 0); - const shootable = { - collisionBounds: { - min: [-16, -16, -24], - max: [16, 16, 40], - }, - enemy, - entity: { index: 20, classname: "monster_zombie" }, - origin: [0, 0, 0], - }; - const draws = [ - ["chance", 0.1], - ["chain", testCase.roll], - ]; - const observedOrder = []; - const now = 9000; - const chain = selectQuakeEnemyAttackChain( - { - ...runtime, - nextRandom: () => { - const [label, value] = draws.shift(); - observedOrder.push(label); - return value; - }, - randomRange: () => { - observedOrder.push("cooldown"); - return 1200; - }, - }, - shootable, - enemy, - 300 * QUAKE_COLLISION_UNIT_SCALE, - [0, 300 * QUAKE_COLLISION_UNIT_SCALE, 0], - now, - ); - - assert.equal(chain, testCase.chain); - assert.equal(enemy.nextAttackAt, now + 1200); - assert.deepEqual(observedOrder, ["chance", "chain", "cooldown"]); - } -}); - -test("wizard wiz_fast10 applies source SUB_AttackFinished cooldown", () => { - const { runtime, traces } = createCombatRuntime(); - const enemy = createEnemyState(12, {}, null, 0); - const shootable = { - enemy, - entity: { index: 12, classname: "monster_wizard" }, - origin: [0, 0, 0], - }; - - runtime.runFrameEvents( - shootable, - { - calls: ["ai_face", "SUB_AttackFinished", "WizardAttackFinished"], - chain: "missile", - chainCycleEnd: false, - classname: "monster_wizard", - events: [], - frame: "magatt2", - frameIndex: 30, - movement: [], - next: "wiz_run1", - sounds: [], - stateName: "wiz_fast10", - }, - "attack", - 7000, - { - enemyEye: [0, 0, 0], - playerOrigin: [1, 0, 0], - profile: { - cooldownMs: 0, - damage: 9, - kind: "projectile", - range: 100, - }, - }, - ); - - assert.equal(enemy.nextAttackAt, 9000); - assert.equal(traces.at(-1)?.kind, "enemy-quakec-attack-finished"); - assert.equal(traces.at(-1)?.details.cooldownMs, 2000); - - enemy.pendingAttack = { - fireAt: Infinity, - quakecChain: "missile", - target: [1, 0, 0], - }; - runtime.finishAttack( - shootable, - { - cooldownMs: 0, - damage: 9, - kind: "projectile", - range: 100, - }, - 7100, - ); - - assert.equal(enemy.nextAttackAt, 9000); -}); - -test("shambler lightning targets the Quake entity origin, not the browser eye origin", () => { - const hits = []; - const playerEyeOrigin = [100 * QUAKE_COLLISION_UNIT_SCALE, 0, 56 * QUAKE_COLLISION_UNIT_SCALE]; - const { runtime, traces } = createCombatRuntime({ - damagePlayer: (amount) => { - hits.push(amount); - return false; - }, - playerDamageBounds: (origin) => ({ - min: [ - origin[0] - 16 * QUAKE_COLLISION_UNIT_SCALE, - origin[1] - 16 * QUAKE_COLLISION_UNIT_SCALE, - origin[2] - 56 * QUAKE_COLLISION_UNIT_SCALE, - ], - max: [ - origin[0] + 16 * QUAKE_COLLISION_UNIT_SCALE, - origin[1] + 16 * QUAKE_COLLISION_UNIT_SCALE, - origin[2], - ], - }), - }); - const enemy = createEnemyState(31, {}, null, 0); - const shootable = { - enemy, - entity: { index: 31, classname: "monster_shambler" }, - origin: [0, 0, 32 * QUAKE_COLLISION_UNIT_SCALE], - }; - - runtime.runFrameEvents( - shootable, - { - calls: ["CastLightning"], - chain: "missile", - chainCycleEnd: false, - classname: "monster_shambler", - events: [{ - call: "CastLightning", - damage: 10, - originOffsetUnits: { up: 40 }, - rangeUnits: 600, - target: "enemy", - targetOffsetUnits: { up: 16 }, - type: "lightning_damage", - }], - frame: "magic6", - frameIndex: 70, - movement: [], - next: "sham_magic9", - sounds: [], - stateName: "sham_magic6", - }, - "attack", - 1200, - { - enemyEye: [0, 0, 0], - playerOrigin: playerEyeOrigin, - profile: { - cooldownMs: 2000, - damage: 120, - kind: "hitscan", - range: 600 * QUAKE_COLLISION_UNIT_SCALE, - }, - }, - ); - - assert.deepEqual(hits, [10]); - const eventTrace = traces.find((trace) => trace.kind === "enemy-quakec-event"); - assert.equal(eventTrace?.details.call, "CastLightning"); - assert.equal(eventTrace?.details.hit, true); - assert.equal(eventTrace?.details.reason, "hit"); -}); diff --git a/test/enemyProjectiles.test.mjs b/test/enemyProjectiles.test.mjs deleted file mode 100644 index 5763e28..0000000 --- a/test/enemyProjectiles.test.mjs +++ /dev/null @@ -1,220 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { importTsModule } from "./importTsModule.mjs"; - -const { - createQuakeEnemyProjectileRuntime, -} = await importTsModule("src/runtime/shootables/enemyProjectiles.ts"); - -function createRuntime({ - consumePlayerPainRandom = () => null, - damagePlayer = () => true, - traceLine = true, - traceNormal = [-1, 0, 0], -} = {}) { - const sounds = []; - let traceCount = 0; - const runtime = createQuakeEnemyProjectileRuntime({ - addMesh: () => ({ - element: { classList: { add: () => undefined } }, - remove: () => undefined, - setTransform: () => undefined, - }), - boundsCenter: (bounds) => [ - (bounds.min[0] + bounds.max[0]) * 0.5, - (bounds.min[1] + bounds.max[1]) * 0.5, - (bounds.min[2] + bounds.max[2]) * 0.5, - ], - currentModelLibrary: () => null, - consumePlayerPainRandom, - damagePlayer, - hasLineOfSight: () => true, - markTrace: () => undefined, - offsetPoint: (origin) => [...origin], - pixelate: () => undefined, - playerDamageBounds: (origin) => ({ - min: [origin[0] - 0.5, origin[1] - 0.5, origin[2] - 0.5], - max: [origin[0] + 0.5, origin[1] + 0.5, origin[2] + 0.5], - }), - playerDamageOrigin: (origin) => [...origin], - playSound: (soundPath, options) => { - sounds.push({ soundPath, options }); - return true; - }, - randomRange: () => 0, - schedulePresentationResync: () => undefined, - traceLine: traceLine ? (_start, _end) => { - traceCount += 1; - if (traceCount > 1) return null; - return { - classname: "worldspawn", - end: [0.5, 0, 0], - fraction: 0.5, - planeNormal: traceNormal, - }; - } : undefined, - }); - - return { runtime, sounds }; -} - -function spawnProjectile(runtime, profile) { - runtime.spawn( - { entity: { index: 1, classname: "monster_ogre" } }, - {}, - [0, 0, 0], - [10, 0, 0], - { - cooldownMs: 0, - damage: 40, - kind: "projectile", - projectileAimDrop: 0, - projectileAimError: 0, - projectileLifetimeMs: 10000, - projectileRadius: 0.1, - projectileSpeed: 10, - projectileVerticalAimError: 0, - range: 100, - ...profile, - }, - 0, - ); -} - -test("ogre grenades play source launch and bounce sounds", () => { - const { runtime, sounds } = createRuntime(); - - spawnProjectile(runtime, { - projectileClassname: "enemy_projectile_grenade", - projectileWorldTouch: "bounce", - }); - runtime.update([100, 100, 100], 0.1, 100); - - assert.deepEqual(sounds.map((sound) => sound.soundPath), [ - "weapons/grenade.wav", - "weapons/bounce.wav", - ]); -}); - -test("ogre grenades play source explosion sound on player splash hit", () => { - const { runtime, sounds } = createRuntime({ traceLine: false }); - - spawnProjectile(runtime, { - projectileClassname: "enemy_projectile_grenade", - projectileSplashDamage: 40, - projectileSplashRadius: 40, - }); - runtime.update([1, 0, 0], 0.1, 100); - - assert.deepEqual(sounds.map((sound) => sound.soundPath), [ - "weapons/grenade.wav", - "weapons/r_exp3.wav", - ]); -}); - -test("ogre grenades play source explosion sound on timeout", () => { - const { runtime, sounds } = createRuntime({ traceLine: false }); - - spawnProjectile(runtime, { - projectileClassname: "enemy_projectile_grenade", - projectileLifetimeMs: 50, - projectileSplashDamage: 40, - projectileSplashOnExpire: true, - projectileSplashRadius: 40, - }); - runtime.update([100, 100, 100], 0.1, 100); - - assert.deepEqual(sounds.map((sound) => sound.soundPath), [ - "weapons/grenade.wav", - "weapons/r_exp3.wav", - ]); -}); - -test("zombie projectiles play source launch and miss sounds on world stop", () => { - const { runtime, sounds } = createRuntime(); - - spawnProjectile(runtime, { - projectileClassname: "enemy_projectile_zombie_grenade", - projectileWorldTouch: "stop", - }); - runtime.update([100, 100, 100], 0.1, 100); - - assert.deepEqual(sounds.map((sound) => sound.soundPath), [ - "zombie/z_shot1.wav", - "zombie/z_miss.wav", - ]); -}); - -test("wizard spikes play the source launch sound when fired", () => { - const { runtime, sounds } = createRuntime(); - - spawnProjectile(runtime, { - projectileClassname: "enemy_projectile_spike", - projectileModelPath: "progs/w_spike.mdl", - }); - - assert.deepEqual(sounds.map((sound) => sound.soundPath), [ - "wizard/wattack.wav", - ]); -}); - -test("boss lavaballs play the source launch sound when fired", () => { - const { runtime, sounds } = createRuntime(); - - spawnProjectile(runtime, { - projectileClassname: "enemy_projectile_lavaball", - projectileModelPath: "progs/lavaball.mdl", - }); - - assert.deepEqual(sounds.map((sound) => sound.soundPath), [ - "boss1/throw.wav", - ]); -}); - -test("nonfatal player projectile damage consumes QuakeC PainSound random", () => { - const painRandoms = []; - const damages = []; - const { runtime } = createRuntime({ - consumePlayerPainRandom: (details) => { - painRandoms.push(details); - return 0.25; - }, - damagePlayer: (amount) => { - damages.push(amount); - return false; - }, - traceLine: false, - }); - - spawnProjectile(runtime, { - projectileClassname: "enemy_projectile_spike", - }); - runtime.update([1, 0, 0], 0.1, 100); - - assert.deepEqual(damages, [40]); - assert.equal(painRandoms.length, 1); - assert.equal(painRandoms[0].damage, 40); - assert.equal(painRandoms[0].projectile, "enemy_projectile_spike"); - assert.equal(painRandoms[0].reason, "hit"); - assert.equal(painRandoms[0].sourceEntityIndex, 1); -}); - -test("lethal player projectile damage does not consume PainSound random", () => { - const painRandoms = []; - const { runtime } = createRuntime({ - consumePlayerPainRandom: (details) => { - painRandoms.push(details); - return 0.25; - }, - damagePlayer: () => true, - traceLine: false, - }); - - spawnProjectile(runtime, { - projectileClassname: "enemy_projectile_spike", - }); - runtime.update([1, 0, 0], 0.1, 100); - - assert.deepEqual(painRandoms, []); -}); diff --git a/test/playerDeath.test.mjs b/test/playerDeath.test.mjs deleted file mode 100644 index b21dc31..0000000 --- a/test/playerDeath.test.mjs +++ /dev/null @@ -1,141 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { importTsModule } from "./importTsModule.mjs"; - -const player = await importTsModule("src/runtime/player.ts"); -const constants = await importTsModule("src/runtime/constants.ts"); -const lifecycle = await importTsModule("src/runtime/app/playerLifecycleFlow.ts"); - -test("player death sound selection follows QuakeC rint(random()*4+1)", () => { - assert.equal(player.quakePlayerDeathSoundIndexFromRandom(0), 1); - assert.equal(player.quakePlayerDeathSoundPathFromRandom(0), "player/death1.wav"); - assert.equal(player.quakePlayerDeathSoundPathFromRandom(0.1249), "player/death1.wav"); - assert.equal(player.quakePlayerDeathSoundPathFromRandom(0.125), "player/death2.wav"); - assert.equal(player.quakePlayerDeathSoundPathFromRandom(0.3749), "player/death2.wav"); - assert.equal(player.quakePlayerDeathSoundPathFromRandom(0.375), "player/death3.wav"); - assert.equal(player.quakePlayerDeathSoundPathFromRandom(0.6249), "player/death3.wav"); - assert.equal(player.quakePlayerDeathSoundPathFromRandom(0.625), "player/death4.wav"); - assert.equal(player.quakePlayerDeathSoundPathFromRandom(0.8749), "player/death4.wav"); - assert.equal(player.quakePlayerDeathSoundPathFromRandom(0.875), "player/death5.wav"); - assert.equal(player.quakePlayerDeathSoundPathFromRandom(0.999), "player/death5.wav"); -}); - -test("player gib death sound selection follows QuakeC random split", () => { - assert.equal(player.quakePlayerGibSoundPathFromRandom(0), "player/gib.wav"); - assert.equal(player.quakePlayerGibSoundPathFromRandom(0.4999), "player/gib.wav"); - assert.equal(player.quakePlayerGibSoundPathFromRandom(0.5), "player/udeath.wav"); - assert.equal(player.quakePlayerGibSoundPathFromRandom(0.999), "player/udeath.wav"); -}); - -test("player death toss consumes random only below QuakeC velocity_z threshold", () => { - const scale = constants.QUAKE_COLLISION_UNIT_SCALE; - assert.equal(player.quakePlayerDeathNeedsTossRandom([0, 0, 9 * scale]), true); - assert.equal(player.quakePlayerDeathNeedsTossRandom([0, 0, 10 * scale]), false); - - assert.deepEqual( - player.quakePlayerDeathTossVelocity([1, 2, 9 * scale], 0.5), - [1, 2, (9 + 150) * scale], - ); - assert.deepEqual( - player.quakePlayerDeathTossVelocity([1, 2, 10 * scale], 0.5), - [1, 2, 10 * scale], - ); -}); - -test("player damage momentum follows QuakeC dir * damage * 8", () => { - const scale = constants.QUAKE_COLLISION_UNIT_SCALE; - - assert.deepEqual( - player.quakePlayerDamageMomentumImpulse([10 * scale, 0, 0], [0, 0, 0], 5), - [40 * scale, 0, 0], - ); - assert.deepEqual( - player.quakePlayerDamageMomentumImpulse([0, 0, 10 * scale], [0, 0, 0], 2), - [0, 0, 16 * scale], - ); - assert.equal(player.quakePlayerDamageMomentumImpulse([0, 0, 0], [0, 0, 0], 10), null); - assert.equal(player.quakePlayerDamageMomentumImpulse([0, 0, 0], null, 10), null); - assert.equal(player.quakePlayerDamageMomentumImpulse([1, 0, 0], [0, 0, 0], 0), null); -}); - -test("player lifecycle plays the source-selected death sound once", () => { - const playedSounds = []; - const traces = []; - const bodyClasses = new Set(); - const flow = lifecycle.createQuakePlayerLifecycleFlow({ - addBodyClasses: (...classNames) => classNames.forEach((className) => bodyClasses.add(className)), - appLoading: () => false, - clearAttackInput: () => undefined, - clearBonusOverlay: () => undefined, - clearCrosshairHit: () => undefined, - clearCrosshairTarget: () => undefined, - clearCrouchInput: () => undefined, - clearDeathDamageFeedback: () => undefined, - clearDeathOverlay: () => undefined, - clearDebugFlyInput: () => undefined, - clearGameRoute: () => undefined, - clearLevelLoadTimer: () => undefined, - clearMegahealthRot: () => undefined, - clearMobileMoveInput: () => undefined, - clearMoveInput: () => undefined, - clearPowerups: () => undefined, - clearText: () => undefined, - clearTextCenterPrint: () => undefined, - clearWeaponViewPunch: () => undefined, - controls: { - lock: () => undefined, - unlock: () => undefined, - update: () => undefined, - }, - currentCollisionWorld: () => ({}), - currentMapName: () => "e1m1", - currentResult: () => null, - exitPointerLockIfHost: () => undefined, - focusHost: () => undefined, - gameplayStarted: () => true, - hasBodyClass: (className) => bodyClasses.has(className), - hasDeathOverlay: () => false, - hideMainMenu: () => undefined, - isMainMenuOpen: () => false, - isMenuPanelOpen: () => false, - jumpVelocity: 4, - loadMap: async () => undefined, - player: () => ({ respawn: () => undefined }), - playDeathSound: (soundPath) => { - playedSounds.push(soundPath); - return true; - }, - pointerTrace: () => undefined, - removeBodyClasses: (...classNames) => classNames.forEach((className) => bodyClasses.delete(className)), - setGameplayStarted: () => undefined, - setLoading: () => undefined, - setPlayerDead: () => undefined, - showDeathDamageFeedback: () => undefined, - showDeathOverlay: () => undefined, - showMainMenu: () => undefined, - startMap: () => "e1m1", - syncPlayerCollision: () => undefined, - trace: (kind, details = {}) => traces.push({ kind, details }), - viewmodel: { - clearFireAnimation: () => undefined, - }, - }); - - const result = flow.showPlayerDeath({ - gibbed: false, - soundPath: "player/death3.wav", - }); - flow.showPlayerDeath({ - gibbed: false, - soundPath: "player/death4.wav", - }); - - assert.equal(result?.soundPlayed, true); - assert.deepEqual(playedSounds, ["player/death3.wav"]); - assert.equal(flow.isPlayerDead(), true); - assert.deepEqual( - traces.filter((entry) => entry.kind === "player-death-sound").map((entry) => entry.details), - [{ gibbed: false, played: true, soundPath: "player/death3.wav" }], - ); -}); diff --git a/test/renderBundlePreloadUrls.test.mjs b/test/renderBundlePreloadUrls.test.mjs deleted file mode 100644 index 183d3a7..0000000 --- a/test/renderBundlePreloadUrls.test.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { importTsModule } from "./importTsModule.mjs"; - -const { - quakeRenderBundlePreloadAssetUrls, -} = await importTsModule("src/runtime/renderBundleMesh.ts"); - -function renderBundle(overrides = {}) { - return { - version: 1, - kind: "polycss-mesh", - polycssVersion: "test", - textureLighting: "baked", - textureQuality: 1, - meshHtml: "", - assetUrls: [], - leafMetadata: [], - polygonCount: 0, - leafCount: 0, - atlasLeafCount: 0, - ...overrides, - }; -} - -test("complete render bundles preload declared asset URLs without scanning mesh HTML", () => { - const urls = quakeRenderBundlePreloadAssetUrls(renderBundle({ - assetUrls: ["/q/b/e1m1/a0.png", "/q/b/e1m1/l0.png", "/q/b/e1m1/l0.png", ""], - assetUrlsComplete: true, - meshHtml: "
", - meshCss: ".mesh .leaf{background-image:url('/q/b/e1m1/unlisted-css.png')}", - })); - - assert.deepEqual(urls, ["/q/b/e1m1/a0.png", "/q/b/e1m1/l0.png"]); -}); - -test("render bundles without complete asset URLs fail before runtime preload", () => { - assert.throws( - () => quakeRenderBundlePreloadAssetUrls(renderBundle({ - assetUrls: ["/q/b/e1m1/a0.png"], - meshHtml: "
", - })), - /assetUrls must be complete/, - ); -});