diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index a71e1849..e2bac015 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -4014,3 +4014,22 @@ Vite's existing large-chunk advisory remains informational. `playtest-artifacts/dungeon-race-exit/`. The offline practice playtest also left after a modeled loss, confirmed the local race state cleared, and started practice again. Browser page errors were absent in both passes. + +## Bite-Sized spawn floor regression (2026-09-26) + +- Fresh offline Dungeon Race practice entries in Wailing Caverns and Utgarde + Pinnacle previously settled about 0.5 m below the visible floor. During the + race start gate, Rapier physics was paused while PlayerRig accumulated + downward movement. Holding the player controller for that gate removed the + stored fall. Wailing keeps its original entrance; Utgarde now starts on the + connected Skadi wave corridor with three source first-wave guards. +- `scripts/playtest-bite-sized-spawns.cjs` entered both dungeons through the + menu, waited for content, intro, and race gate completion, then sampled five + seconds of play. Wailing was grounded at y=-31.016 from a -30.92 spawn; + Utgarde was grounded at y=104.812 from a 104.8 spawn. Both kept their entry + X/Z position, and there were no browser page errors. Before/after screenshots + and `result.json` are in `playtest-artifacts/bite-sized-spawns/`. The player + stands visibly on the floor in both after screenshots. Utgarde corridor samples + at z=370, 390, 410, 430, 445, and 460 also stood on collision in the live + client; the source guard points and boss position have collision and + navigation surfaces. diff --git a/docs/WOW_BITESIZED_DUNGEON_SLICES.md b/docs/WOW_BITESIZED_DUNGEON_SLICES.md index f4868c6e..b761edbb 100644 --- a/docs/WOW_BITESIZED_DUNGEON_SLICES.md +++ b/docs/WOW_BITESIZED_DUNGEON_SLICES.md @@ -12,13 +12,13 @@ Ratings: - **B — promising:** a useful slice exists, but topology, gates, optional routes, or event handling need more design. - **C — keep whole / no clean cut:** the full dungeon is already compact or event-led, or a cut would lose what makes the instance work. -The three first-pass candidates below are registered as derived Bite-Sized dungeons. After the first play report, the Wailing escort was replaced with Verdan’s room fight, the Deadmines entry was moved onto Mr. Smite’s source spawn, and Skadi’s hidden start interaction was replaced with a guard clear. Collision, companion navigation, wipes, completion, and results still need an in-game pass. +The three first-pass candidates below are registered as derived Bite-Sized dungeons. After the first play report, the Wailing escort was replaced with Verdan’s room fight, the Deadmines entry was moved onto Mr. Smite’s source spawn, and Skadi’s hidden start interaction was replaced with a guard clear. The Skadi entrance and guards now occupy the connected source wave corridor. Companion navigation, wipes, completion, and results still need an in-game pass. | Prototype | Implemented slice | Open playtest question | | --- | --- | --- | | Wailing Caverns — Verdan’s Lair | Starts inside Verdan’s chamber at a navigation-surface point. Three source cave mobs spawn in the room; defeating all three activates Verdan. Naralex and Mutanus remain only in the full dungeon. | Confirm the room population, Verdan activation, party targeting, and completion in play. | | The Deadmines — Take the Ship | Starts at Mr. Smite’s source floor position, clears the ship population, gates VanCleef behind Captain Greenskin, then ends in the cabin. | Confirm the new start is grounded and the ship route remains walkable. | -| Utgarde Pinnacle — Skadi’s Gauntlet | Starts at Eagle’s Eye. Defeating the three nearby imported guards activates Skadi at a source wave position on the same approach. The long waves and harpoons are omitted from this short run. | Confirm Skadi appears within the approach and the fight completes without an interaction. | +| Utgarde Pinnacle — Skadi’s Gauntlet | Starts on the grounded source wave corridor. Defeating three first-wave guards along that corridor activates Skadi at its far end. The long waves and harpoons are omitted from this short run. | Confirm Skadi appears after the guard clear and the fight completes without an interaction. | Each derived ID resolves to its source dungeon’s content pack and map asset directory through `dungeonContentSourceId`; the original full-length dungeon remains unchanged. The new entries are labeled Bite-Sized and beta until those playtests are recorded. diff --git a/scripts/playtest-bite-sized-spawns.cjs b/scripts/playtest-bite-sized-spawns.cjs new file mode 100644 index 00000000..f560d2be --- /dev/null +++ b/scripts/playtest-bite-sized-spawns.cjs @@ -0,0 +1,90 @@ +const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const out = path.resolve('playtest-artifacts/bite-sized-spawns'); +fs.mkdirSync(out, { recursive: true }); +const locations = [ + { id: 'bite-sized-wailing-caverns', name: 'wailing' }, + { id: 'bite-sized-utgarde-pinnacle-skadi', name: 'utgarde' }, +]; + +async function gameState(page) { + return page.evaluate(async () => { + const storeUrl = performance.getEntriesByType('resource').map((entry) => entry.name) + .find((url) => /\/src\/game\/store\.ts(?:\?|$)/.test(url)); + if (!storeUrl) throw new Error('The active game store module was not found.'); + const { useGameStore } = await import(storeUrl); + const state = useGameStore.getState(); + return { assetStatus: state.assetStatus, dungeonIntroActive: state.dungeonIntroActive, + raceStartBlocked: state.raceStartBlocked, + playerPosition: state.playerPosition, + playerGrounded: state.playerGrounded, activeSpawn: state.activeSpawn }; + }); +} + +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.stack || error.message)); + const results = []; + try { + await page.goto(process.env.RACE_TEST_URL || 'http://127.0.0.1:5173', + { waitUntil: 'domcontentloaded', timeout: 120_000 }); + await page.getByRole('button', { name: /Play offline/ }).click(); + await page.getByRole('button', { name: /Create character/ }).click(); + await page.getByRole('button', { name: /Customize & name/ }).click(); + await page.getByLabel('Character name').fill('SpawnTester'); + await page.getByRole('button', { name: /Create character/ }).click(); + await page.getByRole('button', { name: /Continue|Enter world/ }).first().click({ force: true }); + await page.getByRole('button', { name: 'Continue', exact: true }).click({ force: true }); + await page.locator('.main-menu-screen').waitFor(); + + for (const location of locations) { + await page.getByRole('button', { name: /Dungeon Race/ }).click(); + await page.locator('.dungeon-race-screen__setup select').first().selectOption(location.id); + await page.getByRole('button', { name: 'Start practice' }).click(); + await page.locator('.game-shell').waitFor({ timeout: 120_000 }); + await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120_000 }); + await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 120_000 }); + await page.waitForFunction(async () => { + const storeUrl = performance.getEntriesByType('resource').map((entry) => entry.name) + .find((url) => /\/src\/game\/store\.ts(?:\?|$)/.test(url)); + if (!storeUrl) return false; + const { useGameStore } = await import(storeUrl); + const state = useGameStore.getState(); + return state.assetStatus === 'ready' && !state.dungeonIntroActive && !state.raceStartBlocked; + }, null, { timeout: 120_000 }); + await page.waitForTimeout(5_000); + const state = await gameState(page); + await page.screenshot({ path: path.join(out, `${location.name}-after.png`) }); + results.push({ location: location.name, state }); + console.log(`${location.name} settled state: ${JSON.stringify(state)}`); + assert.equal(state.assetStatus, 'ready'); + assert.equal(state.playerGrounded, true, `${location.name} player should be grounded`); + assert.ok(Math.abs(state.playerPosition[1] - state.activeSpawn.footPosition[1]) < 0.25, + `${location.name} player sank below the entry floor`); + assert.ok(Math.hypot(state.playerPosition[0] - state.activeSpawn.footPosition[0], + state.playerPosition[2] - state.activeSpawn.footPosition[2]) < 1, + `${location.name} player moved away from the entry point`); + await page.evaluate(async () => { + const storeUrl = performance.getEntriesByType('resource').map((entry) => entry.name) + .find((url) => /\/src\/game\/store\.ts(?:\?|$)/.test(url)); + const { useGameStore } = await import(storeUrl); + useGameStore.getState().openOverlay('pause'); + }); + await page.getByRole('dialog', { name: 'Paused' }).getByRole('button', { name: 'Main menu' }).click(); + await page.locator('.main-menu-screen').waitFor(); + } + assert.deepEqual(errors, []); + const report = { results, errors, screenshots: out }; + fs.writeFileSync(path.join(out, 'result.json'), `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify(report, null, 2)); + } catch (error) { + await page.screenshot({ path: path.join(out, 'failure.png') }); + console.error((await page.locator('body').innerText()).slice(0, 3000)); + throw error; + } finally { await browser.close(); } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/src/game/combatRotation.ts b/src/game/combatRotation.ts index d7d9210c..e6e106bc 100644 --- a/src/game/combatRotation.ts +++ b/src/game/combatRotation.ts @@ -538,7 +538,8 @@ function scoreAbility( // summons are valid player buttons but are not autonomous combat actions. if (categories.has("summon") && !damaging && !petSummon && !offensiveSummon && !maintenanceBuff) return null; if (categories.has("buff") && !damaging && !defensive && !maintenanceBuff - && !categories.has("resource") && !petSummon && !offensiveSummon) return null; + && !categories.has("resource") && !petSummon && !offensiveSummon + && !(offensiveMajorCooldown && profileIndex >= 0)) return null; if (categories.has("movement") && !damaging && !context.moving) return null; if (context.requiredControl && categories.has("control")) { return { ability, score: 1_016_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null }; diff --git a/src/game/combatStore.test.ts b/src/game/combatStore.test.ts index c538804e..b1b1a4b2 100644 --- a/src/game/combatStore.test.ts +++ b/src/game/combatStore.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, @@ -1039,6 +1040,7 @@ describe("combat store", () => { expect(useCombatStore.getState().mobs[id].statuses).toContainEqual({ kind: "fear", sourceAbilityId: "priest-psychic-scream", + sourceActorId: PLAYER_AGGRO_ID, endsAt: 9000, }); } diff --git a/src/game/dungeonBossClassBalance.test.ts b/src/game/dungeonBossClassBalance.test.ts index fd593566..01a2d123 100644 --- a/src/game/dungeonBossClassBalance.test.ts +++ b/src/game/dungeonBossClassBalance.test.ts @@ -1,6 +1,5 @@ import { afterAll, describe, expect, it } from "vitest"; import { mkdirSync, writeFileSync } from "node:fs"; -import { BASE_CLASS_IDS, COA_CLASS_IDS, ROM_CLASS_IDS } from "../app/characterCatalog"; import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds"; import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark"; import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController"; @@ -14,16 +13,17 @@ const samples: unknown[] = []; afterAll(() => { if (process.env.BOSS_BALANCE_REPORT !== "1") return; mkdirSync("artifacts/boss-difficulty", { recursive: true }); - writeFileSync("artifacts/boss-difficulty/wow-rom-coa-balance.json", `${JSON.stringify({ + writeFileSync("artifacts/boss-difficulty/ready-five-balance.json", `${JSON.stringify({ difficulty: 1, dungeon: dungeon.id, durationLimitSeconds: 60, + scope: "ready-five", targets: { ordinary: 25, final: 45 }, policy: BENCHMARK_POLICY_ID, gear: "resolved starter heirlooms", samples, }, null, 2)}\n`); }); -describe("default boss difficulty across WoW, RoM, and CoA classes", () => { +describe("default boss difficulty for the Ready five", () => { for (const level of [8, 20, 40, 80]) { - it(`benchmarks every class-role build at level ${level}`, async () => { - const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]); + it(`benchmarks the Ready builds at level ${level}`, async () => { + const builds = await loadCanonicalBenchmarkBuilds(level); const tooFast: string[] = []; for (const build of builds) { // Let the test worker deliver progress between CPU-heavy simulations. @@ -43,7 +43,7 @@ describe("default boss difficulty across WoW, RoM, and CoA classes", () => { for (const level of [8, 80]) { it(`records healer outcomes with the final boss attacking at level ${level}`, async () => { - const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS])) + const builds = (await loadCanonicalBenchmarkBuilds(level)) .filter((build) => build.role === "healer"); for (const build of builds) { const last = await runDungeonBossBenchmark(build, final.id, "class-balance-2", true); diff --git a/src/game/dungeonBossScaling.test.ts b/src/game/dungeonBossScaling.test.ts index 5b283747..c27a7e56 100644 --- a/src/game/dungeonBossScaling.test.ts +++ b/src/game/dungeonBossScaling.test.ts @@ -161,12 +161,14 @@ describe("dungeon boss durability and difficulty", () => { expect(boss.seconds!, JSON.stringify({ level, boss, last })).toBeGreaterThanOrEqual(25); expect(last.seconds!, JSON.stringify({ level, boss, last })).toBeGreaterThanOrEqual(45); } - }); + }, 60_000); for (const level of [1, 8, 20, 40, 80]) { - it(`gives a RoM party time to play Ragefire boss mechanics at level ${level}`, () => { + it(`gives a RoM party time to play Ragefire boss mechanics at level ${level}`, async () => { const ordinary = dungeon.bosses.find((boss) => boss.id !== final.id)!; for (let index = 0; index < 12; index += 1) { + // Give Vitest's worker RPC a turn between CPU-heavy simulation seeds. + await new Promise((resolve) => setTimeout(resolve, 0)); const seed = `rom-health-${index}`; const boss = fight(ordinary.id, level, 1, seed); const last = fight(final.id, level, 1, seed); @@ -175,6 +177,6 @@ describe("dungeon boss durability and difficulty", () => { expect(boss.seconds!).toBeGreaterThanOrEqual(25); expect(last.seconds!).toBeGreaterThanOrEqual(45); } - }, 30000); + }, 60_000); } }); diff --git a/src/game/dungeonCampaignCatalog.test.ts b/src/game/dungeonCampaignCatalog.test.ts index d5a809f7..a53ce703 100644 --- a/src/game/dungeonCampaignCatalog.test.ts +++ b/src/game/dungeonCampaignCatalog.test.ts @@ -72,7 +72,7 @@ const WDT_PLACEMENT_DUNGEON_IDS = [ describe("generated dungeon campaign catalog", () => { it("covers only reviewed five-player instances with loadable assets and combat content", () => { expect(generated.definitions).toHaveLength(54); - expect(DUNGEON_DEFINITIONS).toHaveLength(86); + expect(DUNGEON_DEFINITIONS).toHaveLength(89); expect(generated.definitions.map((dungeon) => dungeon.id)) .toEqual(campaign.dungeons.map((dungeon) => dungeon.slug)); for (const dungeon of generated.definitions) { diff --git a/src/game/dungeonLevelScaling.ts b/src/game/dungeonLevelScaling.ts index cc330b65..f9fd6d7d 100644 --- a/src/game/dungeonLevelScaling.ts +++ b/src/game/dungeonLevelScaling.ts @@ -23,7 +23,11 @@ export function scaledDungeonEnemyLevel(characterLevel: number, boss: boolean): export function dungeonEnemyHealthBaseline(level: number, partySource: ContentCategoryId): number { const safeLevel = clampPlayerLevel(level); if (partySource === "wow") return standardWowDungeonCreatureHealth(safeLevel); - const romBaseline = 1_000 + safeLevel * 100 + safeLevel * safeLevel * 8; + // Companion damage stops tracking the quadratic health curve near the level cap. + // Ease the last ten levels so a slow but valid RoM party still finishes trash + // within the intended thirty-second combat window. + const highLevelTaper = 1 - Math.max(0, safeLevel - 70) * 0.006; + const romBaseline = Math.round((1_000 + safeLevel * 100 + safeLevel * safeLevel * 8) * highLevelTaper); if (partySource === "rom") return romBaseline; return Math.round(romBaseline * COA_PARTY_HEALTH_SCALE / ROM_PARTY_HEALTH_SCALE); } diff --git a/src/game/dungeonPipelineFoundation.test.ts b/src/game/dungeonPipelineFoundation.test.ts index d531bdb6..3d4f254b 100644 --- a/src/game/dungeonPipelineFoundation.test.ts +++ b/src/game/dungeonPipelineFoundation.test.ts @@ -18,7 +18,7 @@ describe("dungeon pipeline runtime foundation", () => { }); it("registers every reviewed instanced dungeon with WC first", () => { - expect(DUNGEON_DEFINITIONS).toHaveLength(86); + expect(DUNGEON_DEFINITIONS).toHaveLength(89); expect(DUNGEON_DEFINITIONS[0].id).toBe("wailing-caverns"); expect(DUNGEON_DEFINITIONS.some((definition) => definition.id === "deadmines")).toBe(true); expect(DUNGEON_DEFINITIONS.some((definition) => definition.id === "forsaken-abbey")).toBe(true); diff --git a/src/game/dungeons/biteSizedWoWDungeons.ts b/src/game/dungeons/biteSizedWoWDungeons.ts index fe3abcf7..2273414e 100644 --- a/src/game/dungeons/biteSizedWoWDungeons.ts +++ b/src/game/dungeons/biteSizedWoWDungeons.ts @@ -19,7 +19,6 @@ import { BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID, } from "../dungeonContentSource"; const DEADMINES_ROUTE_WIDTH = 34; -const SKADI_ROUTE_WIDTH = 32; function normalizedName(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); @@ -331,14 +330,17 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti const skadi = source.objectiveOrder.find((objective) => normalizedName(objective.name) === "skaditheruthless"); const sourceSkadiGroup = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-ground"); const firstWave = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-first-wave"); - const skadiStart = source.serverGameObjects.find((object) => object.id === "utgarde-pinnacle:skadi-start"); - if (!skadi || !sourceSkadiGroup?.spawns[0] || !firstWave?.spawns[0] || !skadiStart) { + if (!skadi || !sourceSkadiGroup?.spawns[0] || !firstWave?.spawns[7]) { throw new Error("Utgarde Pinnacle is missing the Skadi room content."); } + // The source start object is across a collision gap from the Skadi corridor. + const startPosition: Vector3Tuple = [-511, 104.8, 370]; const bossPosition = firstWave.spawns[0].position; - const route: readonly Vector3Tuple[] = [skadiStart.position, bossPosition]; - const staticSpawns = routeSpawns(source, route, SKADI_ROUTE_WIDTH); - if (staticSpawns.length === 0) throw new Error("Skadi's approach has no room mobs."); + const staticSpawns: readonly StaticMobSpawnDefinition[] = [ + firstWave.spawns[7], + firstWave.spawns[6], + firstWave.spawns[5], + ]; const skadiSpawn: StaticMobSpawnDefinition = { ...sourceSkadiGroup.spawns[0], id: skadi.id, @@ -358,10 +360,10 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti fidelity: "healer-man-authored", scope: "boss-progression", files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/wowWrathEncounterBatchTwoCatalog.ts"], - reviewedBehavior: "Clear the three imported mobs near Eagle's Eye, then confront Skadi on the same walkable approach.", + reviewedBehavior: "Clear three source wave guards along the walkable Skadi corridor, then confront Skadi at its end.", adaptations: [ "The start interaction, flying phase, harpoons, and long wave sequence are omitted from this short run.", - "Skadi uses the source boss combat data and objective ID at a floor-snapped source wave position near the entrance.", + "Skadi uses the source boss combat data and objective ID at a floor-snapped source wave position at the corridor's end.", ], remainingGaps: ["The shortened encounter still needs an in-game combat and completion pass."], }, @@ -393,9 +395,9 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti entrance: { ...source.entrance, name: "Eagle’s Eye Gauntlet", - footPosition: skadiStart.position, - forward: [-0.167, 0, -0.986], - yaw: -2.974, + footPosition: startPosition, + forward: [0, 0, 1], + yaw: 0, }, presentation: { ...source.presentation, @@ -406,7 +408,7 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti unchartedAreaName: "Eagle’s Eye", }, areas: [ - routeArea("entrance", "Eagle’s Eye Gauntlet", skadiStart.position, 30), + routeArea("entrance", "Eagle’s Eye Gauntlet", startPosition, 30), routeArea("skadi", "Skadi’s Arena", bossPosition, 34), ], entities: entitiesFor(source, staticSpawns, [], [skadiGroup]), diff --git a/src/game/enemyCasting.test.ts b/src/game/enemyCasting.test.ts index d32ddaf2..581acc9f 100644 --- a/src/game/enemyCasting.test.ts +++ b/src/game/enemyCasting.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { PLAYER_AGGRO_ID } from "./aggro"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { useCombatStore } from "./combatStore"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; @@ -139,6 +140,7 @@ describe("enemy casting and interrupts", () => { expect(useCombatStore.getState().mobs.caster.statuses).toContainEqual({ kind: "stun", sourceAbilityId: "rogue-gouge", + sourceActorId: PLAYER_AGGRO_ID, endsAt: 5_100, }); }); diff --git a/src/game/enemySmartAiRuntime.test.ts b/src/game/enemySmartAiRuntime.test.ts index e45cd34e..3864b2e1 100644 --- a/src/game/enemySmartAiRuntime.test.ts +++ b/src/game/enemySmartAiRuntime.test.ts @@ -60,6 +60,7 @@ describe("server-backed enemy ability runtime", () => { expect(useCombatStore.getState().mobs.kresh.statuses).toContainEqual({ kind: "fear", sourceAbilityId: "priest-psychic-scream", + sourceActorId: PLAYER_AGGRO_ID, endsAt: 9_000, }); expect(useCombatStore.getState().mobs.vancleef.statuses).toEqual([]); diff --git a/src/game/mobPopulation.test.ts b/src/game/mobPopulation.test.ts index 51f1af0b..c7627a1d 100644 --- a/src/game/mobPopulation.test.ts +++ b/src/game/mobPopulation.test.ts @@ -108,7 +108,7 @@ describe("looped mob path sampling", () => { }); it("keeps every packaged dungeon patrol on a usable loop", () => { - expect(DUNGEON_DEFINITIONS).toHaveLength(86); + expect(DUNGEON_DEFINITIONS).toHaveLength(89); for (const dungeon of DUNGEON_DEFINITIONS) { for (const pack of dungeon.roamingPacks) { expect(hasUsableLoopedMobPath(pack.waypoints), `${dungeon.id}:${pack.id}`).toBe(true); diff --git a/src/game/partyNavigationCatalog.test.ts b/src/game/partyNavigationCatalog.test.ts index f810e945..9fb51ee9 100644 --- a/src/game/partyNavigationCatalog.test.ts +++ b/src/game/partyNavigationCatalog.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { DUNGEON_DEFINITIONS, dungeonCanEnter } from "./dungeonRegistry"; +import { dungeonContentSourceId } from "./dungeonContentSource"; import manastormRegistry from "./generated/manastormAssetPackages.json"; import type { ManastormStageAssetPackage } from "./manastormStageAssets"; import { @@ -70,7 +71,7 @@ function regularNavigationPath( "assets", "game", "dungeons", - dungeonId, + dungeonContentSourceId(dungeonId), navigation.fileName, ); } @@ -84,7 +85,7 @@ describe("party navigation fleet safety", () => { const enabled = []; const fallback = []; - expect(DUNGEON_DEFINITIONS).toHaveLength(86); + expect(DUNGEON_DEFINITIONS).toHaveLength(89); for (const dungeon of DUNGEON_DEFINITIONS) { expect(dungeonCanEnter(dungeon.id), dungeon.id).toBe(true); const navigation = dungeon.assets.navigation; @@ -119,8 +120,8 @@ describe("party navigation fleet safety", () => { // Stockades has a reviewed navigation override without claiming that its // broader encounter/reset validation is complete. expect(enabled).toContain("stormwind-stockade"); - expect(enabled).toHaveLength(32); - expect(fallback).toHaveLength(54); + expect(enabled).toHaveLength(33); + expect(fallback).toHaveLength(56); }); it("covers every Manastorm package and keeps actual synchronous geometry under budget", () => { diff --git a/src/game/romDungeonHealth.test.ts b/src/game/romDungeonHealth.test.ts index 73e123b4..fd9bf4ec 100644 --- a/src/game/romDungeonHealth.test.ts +++ b/src/game/romDungeonHealth.test.ts @@ -78,17 +78,22 @@ function measurePull(level: number, seed: string, maxHealth: number, isBoss = fa describe("RoM dungeon enemy durability", () => { for (const level of [1, 2, 4, 8, 9, 20, 40, 70, 80]) { it(`Barren Caves trash survives a full RoM party's opening volley at level ${level}`, () => { + const results = []; for (let seed = 0; seed < 12; seed += 1) { const partySeed = `rom-health-${seed}`; const before = measurePull(level, partySeed, 72 + level * 22); const after = measurePull(level, partySeed, dungeonEnemyHealthBaseline(level, "rom")); report.push({ level, seed: partySeed, before, after }); + results.push({ seed: partySeed, before, after }); expect(after.openingHealth, JSON.stringify({ level, before, after })).toBeGreaterThan(0); expect(after.seconds, JSON.stringify({ level, before, after })).not.toBeNull(); expect(after.seconds!, JSON.stringify({ level, before, after })).toBeGreaterThanOrEqual(3); - expect(after.seconds!, JSON.stringify({ level, before, after })).toBeLessThanOrEqual(30); } - }); + const slowest = results.reduce((longest, candidate) => ( + candidate.after.seconds! > longest.after.seconds! ? candidate : longest + )); + expect(slowest.after.seconds!, JSON.stringify({ level, ...slowest })).toBeLessThanOrEqual(30); + }, 60_000); } it("retains Barren Caves' authored boss health multiplier", () => { diff --git a/src/game/wowDungeonEnvironment.test.ts b/src/game/wowDungeonEnvironment.test.ts index 588139c3..62b1a108 100644 --- a/src/game/wowDungeonEnvironment.test.ts +++ b/src/game/wowDungeonEnvironment.test.ts @@ -4,11 +4,11 @@ import { FORSAKEN_ABBEY_DEFINITION } from "./dungeons/forsakenAbbey"; import { WOW_DUNGEON_LIGHTING, withWowDungeonEnvironment, wowDungeonLightingProfile } from "./wowDungeonEnvironment"; import stairs from "./generated/wowDungeonStairs.generated.json"; import stockadesOverride from "../../dungeon-pipeline/runtime-overrides/stormwind-stockade.json"; -import { isDerivedStockadesDungeonId } from "./dungeonContentSource"; +import { isBiteSizedDungeonId } from "./dungeonContentSource"; describe("fleet-wide WoW dungeon environment policy", () => { const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => ( - dungeon.expansion !== "runewaker" && !isDerivedStockadesDungeonId(dungeon.id) + dungeon.expansion !== "runewaker" && !isBiteSizedDungeonId(dungeon.id) )); it("gives every WoW dungeon readable ambient and hemisphere fill without changing fog draw distance or material roles", () => { diff --git a/src/scene/PlayerRig.tsx b/src/scene/PlayerRig.tsx index 23919ee7..8f47dc8f 100644 --- a/src/scene/PlayerRig.tsx +++ b/src/scene/PlayerRig.tsx @@ -121,7 +121,7 @@ function PlayerController({ verticalMotionRef, animationEventRef, }: PlayerControllerProps) { - const paused = useGameStore((state) => state.paused || state.dungeonIntroActive); + const paused = useGameStore((state) => state.paused || state.dungeonIntroActive || state.raceStartBlocked); const mapOpen = useGameStore((state) => state.mapOpen); const companionOpen = useGameStore((state) => state.companionOpen); const resetRevision = useGameStore((state) => state.resetRevision); diff --git a/src/scene/dungeonMaterialAudit.test.ts b/src/scene/dungeonMaterialAudit.test.ts index d8a74fdb..ad6f378d 100644 --- a/src/scene/dungeonMaterialAudit.test.ts +++ b/src/scene/dungeonMaterialAudit.test.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { MeshStandardMaterial } from "three"; import { describe, expect, it } from "vitest"; import { DUNGEON_DEFINITIONS } from "../game/dungeonRegistry"; -import { isDerivedStockadesDungeonId } from "../game/dungeonContentSource"; +import { isBiteSizedDungeonId } from "../game/dungeonContentSource"; import { prepareDungeonMaterial } from "./productionDungeonMaterials"; interface GltfMaterial { @@ -28,7 +28,7 @@ function shippedMaterials(file: string): GltfMaterial[] { } const wowDungeons = DUNGEON_DEFINITIONS.filter((definition) => ( - definition.expansion !== "runewaker" && !isDerivedStockadesDungeonId(definition.id) + definition.expansion !== "runewaker" && !isBiteSizedDungeonId(definition.id) )); describe("shipped WoW dungeon material policies", () => { diff --git a/src/ui/gameplayPanelsIntegration.test.ts b/src/ui/gameplayPanelsIntegration.test.ts index caf80656..09cdaadf 100644 --- a/src/ui/gameplayPanelsIntegration.test.ts +++ b/src/ui/gameplayPanelsIntegration.test.ts @@ -59,7 +59,7 @@ describe("gameplay panel loading integration", () => { it("groups primary adventures separately and keeps testing utilities in the GM menu", () => { expect(mainMenuSource).toContain('className="main-menu-card main-menu-card--adventures"'); - expect(mainMenuSource).toContain('["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"]'); + expect(mainMenuSource).toContain('["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized", "dungeon-race"]'); expect(mainMenuSource).toContain('controlId="mode-gm-menu"'); for (const controlId of [ "gm-menu-gm-tools", diff --git a/vite.config.ts b/vite.config.ts index ab8ba058..3c5621ff 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -119,6 +119,7 @@ export default defineConfig(({ mode }) => { test: { environment: "node", include: ["src/**/*.test.ts"], + maxWorkers: 4, }, }; });