diff --git a/package-lock.json b/package-lock.json index 52ecaac5..bf34ccc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.57", + "version": "0.1.58", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.57", + "version": "0.1.58", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 509eb25e..fec345d6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.57", + "version": "0.1.58", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 36905190..a03301ee 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.57", - "versionCode": 1057 + "versionName": "0.1.58", + "versionCode": 1058 } diff --git a/server/database.mjs b/server/database.mjs index 5d219e65..be76c71f 100644 --- a/server/database.mjs +++ b/server/database.mjs @@ -739,19 +739,30 @@ export function openGameDatabase(options = {}) { if (members.some((member) => !member.character_json || !["tank", "healer", "damage"].includes(member.role))) { throw new GameDatabaseError("Every player must select a character and party role before the group can start.", 409, "group_not_ready"); } - const fillWithAi = input?.fillWithAi !== false; + const randomizedBiteSized = type === "dungeon" + && contentId === "randomized-bite-sized-stormwind-stockade"; + const fillWithAi = randomizedBiteSized || input?.fillWithAi !== false; if (input?.includeBetaSpecs !== undefined && typeof input.includeBetaSpecs !== "boolean") { throw new GameDatabaseError("includeBetaSpecs must be boolean.", 400, "invalid_beta_preference"); } - const requestedSize = Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5))); + const includeBetaSpecs = randomizedBiteSized ? false : input?.includeBetaSpecs === true; + const requestedSize = randomizedBiteSized + ? 5 + : Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5))); const partySize = fillWithAi ? Math.max(members.length, requestedSize) : members.length; + const readiness = getCompanionSpecReadiness(); + const companionSpecializationIds = Object.entries(readiness.config.statuses) + .filter(([, status]) => status === "ready" || (includeBetaSpecs && status === "beta")) + .map(([specializationId]) => specializationId); const activity = { id: `activity-${randomUUID()}`, type, contentId, ...(type === "dungeon" ? { difficultyId: input?.difficultyId, bossDifficulty: input?.bossDifficulty ?? 1 } : {}), fillWithAi, - includeBetaSpecs: input?.includeBetaSpecs === true, + includeBetaSpecs, + companionReadinessRevision: readiness.revision, + companionSpecializationIds, partySize, startingLevel: type === "manastorm" ? Math.max(1, Math.trunc(Number(input?.startingLevel) || 1)) diff --git a/server/database.test.mjs b/server/database.test.mjs index fc661bc0..c5d79669 100644 --- a/server/database.test.mjs +++ b/server/database.test.mjs @@ -352,6 +352,24 @@ test("launches players-only and AI-filled group activities only when every membe assert.equal(filled.group.activity.partySize, 5); assert.equal(filled.group.activity.startingLevel, 10); assert.equal(filled.group.activity.includeBetaSpecs, true); + const randomized = database.startOnlineGroupActivity(leader.id, { + type: "dungeon", + contentId: "randomized-bite-sized-stormwind-stockade", + fillWithAi: false, + includeBetaSpecs: true, + partySize: 2, + }); + assert.equal(randomized.group.activity.partySize, 5); + assert.equal(randomized.group.activity.fillWithAi, true); + assert.equal(randomized.group.activity.includeBetaSpecs, false); + assert.equal(randomized.group.activity.companionReadinessRevision, 0); + assert.deepEqual(randomized.group.activity.companionSpecializationIds.sort(), [ + "hunter:marksmanship", + "mage:fire", + "priest:discipline", + "rogue:assassination", + "warrior:protection", + ]); assert.throws(() => database.startOnlineGroupActivity(member.id, { type: "dungeon", contentId: "wailing-caverns", }), (error) => error instanceof GameDatabaseError && error.status === 403); diff --git a/server/dungeon-leaderboards.test.mjs b/server/dungeon-leaderboards.test.mjs index 2a23e608..e96bc144 100644 --- a/server/dungeon-leaderboards.test.mjs +++ b/server/dungeon-leaderboards.test.mjs @@ -58,16 +58,18 @@ test("validates final boss, dungeon/difficulty, duration and party without trust assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "bad" }), { status: 400 }); assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", offset: -1 }), { status: 400 }); })); -test("accepts any rolled bite-sized Stockades boss as the run's full clear", () => withDatabase((database) => { - const dungeon = catalog.dungeons.find((entry) => entry.id === "bite-sized-stormwind-stockade"); - const difficulty = dungeon.difficulties[0]; - assert.equal(difficulty.finalBossIds.length, 5); - for (const [index, bossId] of difficulty.finalBossIds.entries()) { - const payload = run(`bite-sized-${index}-run`, 1000 + index, false, dungeon.id); - payload.killedBossIds = [bossId]; - assert.equal(database.submitDungeonRun(null, payload).run.fullClear, true); +test("accepts any rolled Stockades boss as a full clear for both bite-sized modes", () => withDatabase((database) => { + for (const dungeonId of ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade"]) { + const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId); + const difficulty = dungeon.difficulties[0]; + assert.equal(difficulty.finalBossIds.length, 5); + for (const [index, bossId] of difficulty.finalBossIds.entries()) { + const payload = run(`${dungeonId}-${index}-run`, 1000 + index, false, dungeon.id); + payload.killedBossIds = [bossId]; + assert.equal(database.submitDungeonRun(null, payload).run.fullClear, true); + } + assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, clear: "full" }).total, 5); } - assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, clear: "full" }).total, 5); })); test("public HTTP leaderboards accept completion records and return results with rank while rejecting unfinished runs", async () => { const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-http-")); diff --git a/server/dungeon-reports.mjs b/server/dungeon-reports.mjs index 95aaffed..0f796a49 100644 --- a/server/dungeon-reports.mjs +++ b/server/dungeon-reports.mjs @@ -4,6 +4,7 @@ import policy from "../shared/dungeon-report-policy.json" with { type: "json" }; export const REPORT_DUNGEON_IDS = new Set([ "bite-sized-stormwind-stockade", + "randomized-bite-sized-stormwind-stockade", "forsaken-abbey", ...Object.keys(availability.enabled), ]); @@ -31,6 +32,15 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp reviewed_by TEXT NOT NULL, reviewed_at INTEGER NOT NULL ) STRICT; + CREATE TABLE IF NOT EXISTS randomized_creature_readiness ( + creature_key TEXT PRIMARY KEY, + dungeon_id TEXT NOT NULL, + creature_name TEXT NOT NULL, + creature_kind TEXT NOT NULL CHECK (creature_kind IN ('mob', 'boss')), + status TEXT NOT NULL CHECK (status IN ('ready', 'needs-testing', 'not-ready')), + reviewed_by TEXT NOT NULL, + reviewed_at INTEGER NOT NULL + ) STRICT; `); const columns = new Set(database.prepare("PRAGMA table_info(dungeon_reports)").all().map((column) => column.name)); if (!columns.has("boss_key")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_key TEXT"); @@ -84,6 +94,39 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp } return { + listRandomizedCreatureReadiness() { + return { reviews: database.prepare("SELECT * FROM randomized_creature_readiness ORDER BY creature_kind, creature_name").all().map((row) => ({ + creatureKey: row.creature_key, dungeonId: row.dungeon_id, creatureName: row.creature_name, + creatureKind: row.creature_kind, status: row.status, reviewedBy: row.reviewed_by, + reviewedAt: Number(row.reviewed_at), + })) }; + }, + saveRandomizedCreatureReadiness(account, input) { + if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required"); + if (input?.dungeonId !== "randomized-bite-sized-stormwind-stockade" + || typeof input?.creatureKey !== "string" || !input.creatureKey.startsWith("stormwind-stockade:") + || input.creatureKey.length > 256 || typeof input?.creatureName !== "string" + || !input.creatureName.trim() || input.creatureName.length > 128 + || !["mob", "boss"].includes(input.creatureKind)) { + throw new ErrorType("Choose a valid randomized creature.", 400, "invalid_creature"); + } + if (!["ready", "needs-testing", "not-ready"].includes(input.status)) { + throw new ErrorType("Choose a valid creature readiness status.", 400, "invalid_creature_status"); + } + const review = { + creatureKey: input.creatureKey, dungeonId: input.dungeonId, + creatureName: input.creatureName.trim(), creatureKind: input.creatureKind, + status: input.status, reviewedBy: account.username, reviewedAt: Date.now(), + }; + database.prepare(`INSERT INTO randomized_creature_readiness VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(creature_key) DO UPDATE SET dungeon_id = excluded.dungeon_id, + creature_name = excluded.creature_name, creature_kind = excluded.creature_kind, + status = excluded.status, reviewed_by = excluded.reviewed_by, + reviewed_at = excluded.reviewed_at`).run( + review.creatureKey, review.dungeonId, review.creatureName, review.creatureKind, + review.status, review.reviewedBy, review.reviewedAt); + return review; + }, listBossTestingReviews() { return { reviews: database.prepare("SELECT * FROM boss_testing_reviews ORDER BY boss_key").all().map((row) => ({ bossKey: row.boss_key, dungeonId: row.dungeon_id, bossName: row.boss_name, diff --git a/server/dungeon-reports.test.mjs b/server/dungeon-reports.test.mjs index 93d09c15..e7c98338 100644 --- a/server/dungeon-reports.test.mjs +++ b/server/dungeon-reports.test.mjs @@ -27,9 +27,11 @@ test("validates comments and dungeon IDs and stamps new reports as Submitted", ( assert.equal(report.submittedBy, "Reporter"); assert.equal(database.submitDungeonReport(null, { dungeonId: "forsaken-abbey", comment: " Door is stuck\r\nAfter a wipe " }).comment, "Door is stuck\nAfter a wipe"); assert.equal(database.submitDungeonReport(null, { dungeonId: "bite-sized-stormwind-stockade", comment: "Wall issue" }).status, "Submitted"); + assert.equal(database.submitDungeonReport(null, { dungeonId: "randomized-bite-sized-stormwind-stockade", comment: "Random mob issue" }).status, "Submitted"); assert.deepEqual(database.listDungeonReports().openCounts, { "bite-sized-stormwind-stockade": 1, "forsaken-abbey": 1, + "randomized-bite-sized-stormwind-stockade": 1, "stormwind-stockade": 1, }); })); @@ -98,6 +100,29 @@ test("persists boss readiness and structured boss reports without changing ordin } finally { database?.close(); rmSync(directory, { recursive: true, force: true }); } }); +test("persists randomized mob and boss readiness and requires a server-authenticated GM", () => withDatabase(async (database) => { + const gm = (await database.register("Phenom", "readiness-admin")).account; + const player = (await database.register("ReadinessPlayer", "readiness-player")).account; + const creature = { + creatureKey: "stormwind-stockade:entity-1711", + dungeonId: "randomized-bite-sized-stormwind-stockade", + creatureName: "Defias Convict", + creatureKind: "mob", + }; + assert.throws(() => database.saveRandomizedCreatureReadiness(player, { ...creature, status: "ready" }), { status: 403 }); + for (const status of ["ready", "needs-testing", "not-ready"]) { + assert.equal(database.saveRandomizedCreatureReadiness(gm, { ...creature, status }).status, status); + } + assert.throws(() => database.saveRandomizedCreatureReadiness(gm, { ...creature, status: "Closed" }), { code: "invalid_creature_status" }); + assert.throws(() => database.saveRandomizedCreatureReadiness(gm, { ...creature, creatureKind: "pet", status: "ready" }), { code: "invalid_creature" }); + assert.deepEqual(database.listRandomizedCreatureReadiness().reviews[0], { + ...creature, + status: "not-ready", + reviewedBy: "Phenom", + reviewedAt: database.listRandomizedCreatureReadiness().reviews[0].reviewedAt, + }); +})); + test("serves public reports and GM-only status API with shelved modes disabled", async () => { const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-")); const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false }); @@ -115,6 +140,16 @@ test("serves public reports and GM-only status API with shelved modes disabled", const labels = await (await fetch(`${origin}/api/boss-testing/reviews`)).json(); assert.equal(labels.reviews[0].status, "ready"); const gm = await runtime.database.register("Phenom", "report-admin"); + const creature = { creatureKey: "stormwind-stockade:entity-1711", dungeonId: "randomized-bite-sized-stormwind-stockade", + creatureName: "Defias Convict", creatureKind: "mob", status: "ready" }; + const playerDenied = await fetch(`${origin}/api/gm/randomized-creature-readiness`, { method: "PUT", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${player.token}` }, body: JSON.stringify(creature) }); + assert.equal(playerDenied.status, 403); + const readinessSaved = await fetch(`${origin}/api/gm/randomized-creature-readiness`, { method: "PUT", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${gm.token}` }, body: JSON.stringify(creature) }); + assert.equal(readinessSaved.status, 200); + const readiness = await (await fetch(`${origin}/api/randomized-creature-readiness`)).json(); + assert.equal(readiness.reviews[0].status, "ready"); const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token); assert.equal(created.status, 201); const { report } = await created.json(); diff --git a/server/server.mjs b/server/server.mjs index 1d9154de..2c3f7f9e 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -336,6 +336,18 @@ export function createGameServer(options = {}) { json(response, 200, { review: gameDatabase.saveBossTestingReview(account, await readJson(request, 8 * 1024)) }); return; } + if (url.pathname === "/api/randomized-creature-readiness" && request.method === "GET") { + json(response, 200, gameDatabase.listRandomizedCreatureReadiness()); + return; + } + if (url.pathname === "/api/gm/randomized-creature-readiness" && request.method === "PUT") { + const account = gameDatabase.authenticate(bearerToken(request)); + json(response, 200, { review: gameDatabase.saveRandomizedCreatureReadiness( + account, + await readJson(request, 8 * 1024), + ) }); + return; + } if (url.pathname === "/api/dungeon-reports" && request.method === "GET") { json(response, 200, gameDatabase.listDungeonReports({ status: url.searchParams.get("status"), diff --git a/src/app/onlineGroupStore.ts b/src/app/onlineGroupStore.ts index 6463b555..5958f55b 100644 --- a/src/app/onlineGroupStore.ts +++ b/src/app/onlineGroupStore.ts @@ -30,7 +30,9 @@ interface OnlineGroupState { leave: (session: PlayerSession) => Promise; remove: (session: PlayerSession, accountId: string) => Promise; updateMember: (session: PlayerSession, character: CharacterProfile, role: PartyRole) => Promise; - startActivity: (session: PlayerSession, input: Omit) => Promise; + startActivity: (session: PlayerSession, input: Omit & { + readonly fillWithAi?: boolean; + }) => Promise; clearActivity: (session: PlayerSession) => Promise; setFillWithAi: (fillWithAi: boolean) => void; consumeActivity: (activityId: string) => void; @@ -127,7 +129,10 @@ export const useOnlineGroupStore = create((set, get) => ({ startActivity: async (session, input) => { set({ busy: true, error: "" }); try { - const snapshot = await startOnlineGroupActivity(session, { ...input, fillWithAi: get().fillWithAi }); + const snapshot = await startOnlineGroupActivity(session, { + ...input, + fillWithAi: input.fillWithAi ?? get().fillWithAi, + }); set({ snapshot, busy: false }); return true; } catch (error) { diff --git a/src/app/onlineGroupTypes.ts b/src/app/onlineGroupTypes.ts index 2aaa4499..3d0e8e4d 100644 --- a/src/app/onlineGroupTypes.ts +++ b/src/app/onlineGroupTypes.ts @@ -29,6 +29,8 @@ export interface OnlineGroupActivity { readonly contentId: string; readonly fillWithAi: boolean; readonly includeBetaSpecs?: boolean; + readonly companionReadinessRevision?: number; + readonly companionSpecializationIds?: readonly string[]; readonly partySize: number; readonly startingLevel: number | null; readonly startedAt: number; diff --git a/src/app/randomizedCreatureReadiness.ts b/src/app/randomizedCreatureReadiness.ts new file mode 100644 index 00000000..7374a6d2 --- /dev/null +++ b/src/app/randomizedCreatureReadiness.ts @@ -0,0 +1,81 @@ +import { create } from "zustand"; +import { requestOnlineJson } from "./onlineAccountClient"; +import type { PlayerSession } from "./types"; +import type { RandomizedBiteSizedCreature, RandomizedCreatureKind } from "../game/dungeons/randomizedBiteSizedStockades"; + +export const CREATURE_READINESS_STATUSES = ["ready", "needs-testing", "not-ready"] as const; +export type CreatureReadinessStatus = (typeof CREATURE_READINESS_STATUSES)[number]; + +export const CREATURE_READINESS_LABELS: Readonly> = { + ready: "Ready", + "needs-testing": "Needs Tested", + "not-ready": "Not Ready", +}; + +export interface RandomizedCreatureReview { + readonly creatureKey: string; + readonly dungeonId: string; + readonly creatureName: string; + readonly creatureKind: RandomizedCreatureKind; + readonly status: CreatureReadinessStatus; + readonly reviewedBy: string; + readonly reviewedAt: number; +} + +interface RandomizedCreatureReadinessState { + readonly reviews: Readonly>; + readonly loading: boolean; + readonly error: string; + refresh: () => Promise; + save: ( + session: PlayerSession, + creature: RandomizedBiteSizedCreature, + status: CreatureReadinessStatus, + ) => Promise; +} + +export const useRandomizedCreatureReadiness = create((set, get) => ({ + reviews: {}, + loading: false, + error: "", + refresh: async () => { + set({ loading: true, error: "" }); + try { + const payload = await requestOnlineJson<{ reviews: RandomizedCreatureReview[] }>( + "/api/randomized-creature-readiness", + ); + set({ reviews: Object.fromEntries(payload.reviews.map((review) => [review.creatureKey, review])) }); + } catch (error) { + set({ error: error instanceof Error ? error.message : "Unable to load creature readiness." }); + } finally { + set({ loading: false }); + } + }, + save: async (session, creature, status) => { + if (session.kind !== "account" || !session.accessToken) return false; + set({ loading: true, error: "" }); + try { + const payload = await requestOnlineJson<{ review: RandomizedCreatureReview }>( + "/api/gm/randomized-creature-readiness", + { + method: "PUT", + body: JSON.stringify({ + creatureKey: creature.key, + dungeonId: "randomized-bite-sized-stormwind-stockade", + creatureName: creature.name, + creatureKind: creature.kind, + status, + }), + }, + session.accessToken, + ); + set({ reviews: { ...get().reviews, [payload.review.creatureKey]: payload.review } }); + return true; + } catch (error) { + set({ error: error instanceof Error ? error.message : "Unable to save creature readiness." }); + return false; + } finally { + set({ loading: false }); + } + }, +})); diff --git a/src/game/PartyBridge.tsx b/src/game/PartyBridge.tsx index d02c280a..6eabc551 100644 --- a/src/game/PartyBridge.tsx +++ b/src/game/PartyBridge.tsx @@ -9,6 +9,7 @@ import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupS import { useDungeonSimulationStore } from "./dungeonSimulationStore"; import { useCompanionSpecReadinessStore } from "./companionSpecReadinessStore"; import { useCompanionPartyPreferences } from "./companionPartyPreferences"; +import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource"; /** Owns party lifecycle; the combat interval itself remains in CombatBridge. */ export function PartyBridge() { @@ -17,6 +18,7 @@ export function PartyBridge() { const gameMode = useGameStore((state) => state.gameMode); const manastormPartySize = useManastormStore((state) => state.partySize); const onlineActivity = useOnlineGroupStore((state) => state.activeActivity); + const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group); const level = useCombatStore((state) => state.level); const simulationMode = useDungeonSimulationStore((state) => state.sessionMode); const simulationSeed = useDungeonSimulationStore((state) => state.seed); @@ -35,20 +37,30 @@ export function PartyBridge() { && onlineActivity.type === (gameMode === "manastorm" ? "manastorm" : "dungeon") ? onlineGroupAiPartySize(onlineActivity.partySize) : requestedPartySize; + const sharedOnlineParty = onlineActivity && onlineGroup?.activity?.id === onlineActivity.id + ? onlineGroup.members + : []; + const randomizedReadyOnly = onlineActivity?.type === "dungeon" + && onlineActivity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID; const companionOptions = { readinessPolicy, - readinessPolicyRevision, - includeBetaSpecs: onlineActivity?.includeBetaSpecs ?? deviceIncludeBetaSpecs, + readinessPolicyRevision: onlineActivity?.companionReadinessRevision ?? readinessPolicyRevision, + includeBetaSpecs: randomizedReadyOnly ? false : onlineActivity?.includeBetaSpecs ?? deviceIncludeBetaSpecs, excludedPlayerSpecializationId: character.specializationId, + excludedPlayerSpecializationIds: sharedOnlineParty.flatMap((member) => ( + member.character?.specializationId ? [member.character.specializationId] : [] + )), + allowedCompanionSpecializationIds: onlineActivity?.companionSpecializationIds, + occupiedHumanRoles: sharedOnlineParty.flatMap((member) => member.role ? [member.role] : []), }; if (simulationMode === "play") { party.initializeParty( - character.id, + onlineActivity?.id ?? character.id, combat.level, combat.health, playerRole, aiPartySize, - character.categoryId ?? "wow", + randomizedReadyOnly ? "wow" : character.categoryId ?? "wow", companionOptions, ); } else { @@ -59,7 +71,9 @@ export function PartyBridge() { combat.applyDefaultPartyBuffs(); combat.applyPrecombatPreparation(); resetPartyRuntimeClock(); - }, [character?.id, deviceIncludeBetaSpecs, gameMode, manastormPartySize, onlineActivity?.id, onlineActivity?.includeBetaSpecs, playerRole, readinessPolicy, readinessPolicyRevision, simulationMode, simulationSeed]); + }, [character?.id, deviceIncludeBetaSpecs, gameMode, manastormPartySize, onlineActivity?.id, + onlineActivity?.includeBetaSpecs, onlineGroup?.updatedAt, playerRole, readinessPolicy, + readinessPolicyRevision, simulationMode, simulationSeed]); useEffect(() => { const party = usePartyStore.getState(); diff --git a/src/game/dungeonCampaignCatalog.test.ts b/src/game/dungeonCampaignCatalog.test.ts index db23cd37..d5a809f7 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(85); + expect(DUNGEON_DEFINITIONS).toHaveLength(86); 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/dungeonCatalog.test.ts b/src/game/dungeonCatalog.test.ts index dad12a1a..9b96f16c 100644 --- a/src/game/dungeonCatalog.test.ts +++ b/src/game/dungeonCatalog.test.ts @@ -12,9 +12,9 @@ import { compareDungeonReleaseOrder } from "./dungeonReleaseStage"; import availability from "./generated/dungeonAvailability.json"; describe("dungeon catalog", () => { - it("lists the complete campaign plus the bite-sized Stockades variant", () => { - expect(DUNGEON_CATALOG).toHaveLength(85); - expect(DUNGEON_CATALOG[0]).toMatchObject({ + it("lists the complete campaign plus both bite-sized Stockades variants", () => { + expect(DUNGEON_CATALOG).toHaveLength(86); + expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({ id: "stormwind-stockade", releaseStage: "beta", available: true, @@ -74,9 +74,13 @@ describe("dungeon catalog", () => { } }); - it("keeps both Stockades experiences in Beta and alphabetizes each release group", () => { + it("keeps all Stockades experiences in Beta and alphabetizes each release group", () => { expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id)) - .toEqual(["stormwind-stockade", "bite-sized-stormwind-stockade"]); + .toEqual([ + "randomized-bite-sized-stormwind-stockade", + "stormwind-stockade", + "bite-sized-stormwind-stockade", + ]); expect(DUNGEON_CATALOG.filter((dungeon) => !dungeon.id.includes("stormwind-stockade")) .every((dungeon) => dungeon.releaseStage === "alpha")) .toBe(true); @@ -98,6 +102,6 @@ describe("dungeon catalog", () => { it("shares the complete dungeon identity list used to validate reports on the server", () => { expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort()) - .toEqual(["bite-sized-stormwind-stockade", "forsaken-abbey", ...Object.keys(availability.enabled)].sort()); + .toEqual(["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade", "forsaken-abbey", ...Object.keys(availability.enabled)].sort()); }); }); diff --git a/src/game/dungeonCatalog.ts b/src/game/dungeonCatalog.ts index e5702b7c..4dc9bf7e 100644 --- a/src/game/dungeonCatalog.ts +++ b/src/game/dungeonCatalog.ts @@ -1,7 +1,10 @@ import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry"; import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories"; import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage"; -import { BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource"; +import { + BITE_SIZED_STOCKADES_DUNGEON_ID, + RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, +} from "./dungeonContentSource"; const SOURCE_DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[]; const DUNGEON_CATEGORY_IDS = ["wow", "rom", "bite-sized"] as const; @@ -40,6 +43,7 @@ export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze( DUNGEON_DEFINITIONS.map((definition) => ({ id: definition.id as DungeonId, category: definition.id === BITE_SIZED_STOCKADES_DUNGEON_ID + || definition.id === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID ? "bite-sized" : definition.expansion === "runewaker" ? "rom" : "wow", title: definition.title, @@ -61,7 +65,9 @@ export function dungeonById(id: string): DungeonCatalogEntry | null { } export function dungeonsByCategory(category: DungeonCategoryId): readonly DungeonCatalogEntry[] { - return DUNGEON_CATALOG.filter((dungeon) => dungeon.category === category); + return DUNGEON_CATALOG.filter((dungeon) => ( + dungeon.category === category && dungeon.id !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID + )); } /** Selects from enterable category content and avoids an immediate repeat when alternatives exist. */ diff --git a/src/game/dungeonContentSource.test.ts b/src/game/dungeonContentSource.test.ts index 9b77f896..404197cc 100644 --- a/src/game/dungeonContentSource.test.ts +++ b/src/game/dungeonContentSource.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { BITE_SIZED_STOCKADES_DUNGEON_ID, + RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID, dungeonContentPackId, dungeonContentSourceId, @@ -10,6 +11,8 @@ describe("derived dungeon content sources", () => { it("reuses Stockades files for the bite-sized variant without changing ordinary pack ids", () => { expect(dungeonContentSourceId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID); expect(dungeonContentPackId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("dungeon:stormwind-stockade"); + expect(dungeonContentSourceId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID); + expect(dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("dungeon:stormwind-stockade"); expect(dungeonContentPackId("deadmines")).toBe("dungeon:deadmines"); }); }); diff --git a/src/game/dungeonContentSource.ts b/src/game/dungeonContentSource.ts index 3760c7f4..bb2cb67f 100644 --- a/src/game/dungeonContentSource.ts +++ b/src/game/dungeonContentSource.ts @@ -1,10 +1,17 @@ export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade"; export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade"; +export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade"; const DUNGEON_CONTENT_SOURCE_IDS: Readonly> = Object.freeze({ [BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID, + [RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID, }); +export function isDerivedStockadesDungeonId(dungeonId: string): boolean { + return dungeonId === BITE_SIZED_STOCKADES_DUNGEON_ID + || dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID; +} + /** Derived dungeon variants share the installed assets of their source dungeon. */ export function dungeonContentSourceId(dungeonId: string): string { return DUNGEON_CONTENT_SOURCE_IDS[dungeonId] ?? dungeonId; diff --git a/src/game/dungeonLeaderboardCatalog.test.ts b/src/game/dungeonLeaderboardCatalog.test.ts index 9efce6a0..204a7469 100644 --- a/src/game/dungeonLeaderboardCatalog.test.ts +++ b/src/game/dungeonLeaderboardCatalog.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { CLASSES } from "../app/characterCatalog"; import { DUNGEON_DEFINITIONS } from "./dungeonRegistry"; import { dungeonBossesForDifficulty, finalDungeonBoss } from "./dungeonRunStore"; -import { BITE_SIZED_STOCKADES_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource"; +import { isDerivedStockadesDungeonId, STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource"; import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "./dungeons/biteSizedStockades"; const stockades = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === STORMWIND_STOCKADE_DUNGEON_ID)!; @@ -15,19 +15,19 @@ describe("dungeon leaderboard catalog", () => { it("keeps the server's final bosses, boss lists, difficulties, and classes aligned with the game", () => { const catalog = { dungeons: DUNGEON_DEFINITIONS.map((dungeon) => ({ id: dungeon.id, title: dungeon.title, - finalBossId: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID + finalBossId: isDerivedStockadesDungeonId(dungeon.id) ? biteSizedBosses[0].id : finalDungeonBoss(dungeon)?.id ?? "", - bosses: (dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID ? biteSizedBosses : dungeon.bosses) + bosses: (isDerivedStockadesDungeonId(dungeon.id) ? biteSizedBosses : dungeon.bosses) .map((boss) => ({ id: boss.id, name: boss.name })), difficulties: dungeon.difficultyVariants.map((difficulty) => ({ id: difficulty.id, name: difficulty.difficulty, - bossIds: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID + bossIds: isDerivedStockadesDungeonId(dungeon.id) ? biteSizedBosses.map((boss) => boss.id) : dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id), - finalBossId: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID + finalBossId: isDerivedStockadesDungeonId(dungeon.id) ? biteSizedBosses[0].id : finalDungeonBoss(dungeon, difficulty.id)?.id ?? "", - ...(dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID + ...(isDerivedStockadesDungeonId(dungeon.id) ? { finalBossIds: biteSizedBosses.map((boss) => boss.id) } : {}), })), @@ -35,7 +35,7 @@ describe("dungeon leaderboard catalog", () => { })).sort((a, b) => a.id.localeCompare(b.id)), classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })), }; - expect(catalog.dungeons).toHaveLength(85); + expect(catalog.dungeons).toHaveLength(86); expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]); const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json"; if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`); diff --git a/src/game/dungeonPipelineFoundation.test.ts b/src/game/dungeonPipelineFoundation.test.ts index 852f0392..d531bdb6 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(85); + expect(DUNGEON_DEFINITIONS).toHaveLength(86); 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/dungeonRegistry.ts b/src/game/dungeonRegistry.ts index 0de5adaf..b2a411b2 100644 --- a/src/game/dungeonRegistry.ts +++ b/src/game/dungeonRegistry.ts @@ -23,10 +23,12 @@ import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCat import { withWowDungeonEnvironment } from "./wowDungeonEnvironment"; import { BITE_SIZED_STOCKADES_DUNGEON_ID, + RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID, dungeonContentSourceId, } from "./dungeonContentSource"; import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades"; +import { createRandomizedBiteSizedStockadesDefinition } from "./dungeons/randomizedBiteSizedStockades"; export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults"; @@ -200,9 +202,11 @@ const stockadesDefinition = adaptedSourceRegistry[STORMWIND_STOCKADE_DUNGEON_ID] if (!stockadesDefinition) throw new Error("Stormwind Stockade is required for its bite-sized variant."); const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template"); +const randomizedBiteSizedStockadesTemplate = createRandomizedBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template"); const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [ ...adaptedSourceEntries, [biteSizedStockadesTemplate.id, biteSizedStockadesTemplate], + [randomizedBiteSizedStockadesTemplate.id, randomizedBiteSizedStockadesTemplate], ]; export const DUNGEON_REGISTRY: Readonly> = Object.freeze( @@ -235,8 +239,12 @@ export function clearBossTestingDungeons(): void { /** Materializes seeded variants after a run id exists; ordinary dungeons keep their catalog definition. */ export function prepareDungeonSessionDefinition(id: string, seed: string): DungeonDefinition | null { - if (id !== BITE_SIZED_STOCKADES_DUNGEON_ID) return dungeonDefinitionById(id); - const definition = createBiteSizedStockadesDefinition(stockadesDefinition, seed); + if (id !== BITE_SIZED_STOCKADES_DUNGEON_ID && id !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) { + return dungeonDefinitionById(id); + } + const definition = id === BITE_SIZED_STOCKADES_DUNGEON_ID + ? createBiteSizedStockadesDefinition(stockadesDefinition, seed) + : createRandomizedBiteSizedStockadesDefinition(stockadesDefinition, seed); sessionDungeonDefinitions.set(id, definition); return definition; } diff --git a/src/game/dungeonReleaseStage.ts b/src/game/dungeonReleaseStage.ts index 61089ab7..428d4718 100644 --- a/src/game/dungeonReleaseStage.ts +++ b/src/game/dungeonReleaseStage.ts @@ -8,7 +8,11 @@ export const DUNGEON_RELEASE_STAGE_LABELS: Readonly>> 0; +} + +function placedEntityIds(source: DungeonDefinition): ReadonlySet { + return new Set([ + ...source.staticSpawns.map((spawn) => spawn.entityId), + ...source.roamingPacks.flatMap((pack) => pack.members.map((member) => member.entityId)), + ]); +} + +/** The immutable review catalog used by both seeded generation and the GM readiness screen. */ +export function randomizedBiteSizedCreatureCatalog( + source: DungeonDefinition, +): readonly RandomizedBiteSizedCreature[] { + if (source.id !== STORMWIND_STOCKADE_DUNGEON_ID) { + throw new Error(`Randomized Bite-Sized Stockades requires ${STORMWIND_STOCKADE_DUNGEON_ID}.`); + } + const placed = placedEntityIds(source); + const bossEntityIds = new Set(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => { + const spawn = source.staticSpawns.find((entry) => entry.id === candidate.sourceSpawnId); + if (!spawn) throw new Error(`Stockades boss source is missing: ${candidate.sourceSpawnId}`); + return spawn.entityId; + })); + + return Object.values(source.entities) + .filter((entity) => placed.has(entity.id) && (entity.kind === "mob" || bossEntityIds.has(entity.id))) + .map((entity) => ({ + key: `${source.id}:${entity.id}`, + sourceDungeonId: source.id, + sourceDungeonTitle: source.title, + entityId: entity.id, + name: entity.name, + kind: entity.kind, + serverEntry: entity.combat?.serverEntry ?? null, + })) + .sort((left, right) => left.kind.localeCompare(right.kind) || left.name.localeCompare(right.name)); +} + +/** + * Rerolls every ordinary corridor spawn from the complete placed Stockades mob + * pool. The existing bite-sized builder remains the authority for geometry, + * navigation, sealed wings, and the independently seeded five-boss roll. + */ +export function createRandomizedBiteSizedStockadesDefinition( + source: DungeonDefinition, + seed: string, +): DungeonDefinition { + const base = createBiteSizedStockadesDefinition(source, seed); + const catalog = randomizedBiteSizedCreatureCatalog(source); + const mobCandidates = catalog.filter((candidate) => candidate.kind === "mob"); + if (!mobCandidates.length) throw new Error("Randomized Bite-Sized Stockades has no ordinary mob candidates."); + const bossSpawnId = base.bosses[0]?.id; + const randomizedSpawns = base.staticSpawns.map((spawn) => { + if (spawn.id === bossSpawnId) return spawn; + const candidate = mobCandidates[seedHash(`${seed}:${spawn.id}`) % mobCandidates.length]; + return { ...spawn, entityId: candidate.entityId }; + }); + const usedEntityIds = new Set(randomizedSpawns.map((spawn) => spawn.entityId)); + const entities = Object.fromEntries( + Object.entries(source.entities).filter(([entityId]) => usedEntityIds.has(entityId)), + ) as Readonly>; + + return { + ...base, + id: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, + title: "Randomized Bite-Sized: Cellblock Shuffle", + presentation: { + ...base.presentation, + location: "Stormwind Stockade · Shifting Cellblock", + theme: "Randomized Bite-Sized", + summary: "Every enemy slot and the final warden are rerolled for a fast, unpredictable one-boss run.", + loadingMessage: "Shuffling prisoners, guards, and the final warden...", + }, + entities, + staticSpawns: randomizedSpawns, + encounters: (base.encounters ?? []).map((encounter) => ({ + ...encounter, + id: `${RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`, + dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, + source: encounter.source ? { + ...encounter.source, + files: ["src/game/dungeons/randomizedBiteSizedStockades.ts"], + reviewedBehavior: "Every corridor slot and the final Stockades boss are deterministic rolls from the run seed.", + adaptations: [ + ...encounter.source.adaptations, + "Each ordinary spawn slot independently selects a placed Stockades mob archetype from the immutable review catalog.", + ], + } : encounter.source, + })), + validation: { + ...base.validation, + warnings: [ + ...base.validation.warnings, + "Randomized creature identities are Healer Man-authored and deterministic per run seed.", + ], + }, + }; +} diff --git a/src/game/generated/dungeonLeaderboardCatalog.generated.json b/src/game/generated/dungeonLeaderboardCatalog.generated.json index 18bb5659..fb8812d8 100644 --- a/src/game/generated/dungeonLeaderboardCatalog.generated.json +++ b/src/game/generated/dungeonLeaderboardCatalog.generated.json @@ -2092,6 +2092,55 @@ ], "defaultDifficultyId": "normal" }, + { + "id": "randomized-bite-sized-stormwind-stockade", + "title": "Randomized Bite-Sized: Cellblock Shuffle", + "finalBossId": "creature-84027", + "bosses": [ + { + "id": "creature-84027", + "name": "Targorr the Dread" + }, + { + "id": "creature-79088", + "name": "Kam Deepfury" + }, + { + "id": "creature-79098", + "name": "Hamhock" + }, + { + "id": "creature-79124", + "name": "Bazil Thredd" + }, + { + "id": "creature-79116", + "name": "Dextren Ward" + } + ], + "difficulties": [ + { + "id": "normal", + "name": "normal", + "bossIds": [ + "creature-84027", + "creature-79088", + "creature-79098", + "creature-79124", + "creature-79116" + ], + "finalBossId": "creature-84027", + "finalBossIds": [ + "creature-84027", + "creature-79088", + "creature-79098", + "creature-79124", + "creature-79116" + ] + } + ], + "defaultDifficultyId": "normal" + }, { "id": "razorfen-downs", "title": "Razorfen Downs", diff --git a/src/game/mobPopulation.test.ts b/src/game/mobPopulation.test.ts index 81809955..51f1af0b 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(85); + expect(DUNGEON_DEFINITIONS).toHaveLength(86); 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 51fa9808..f810e945 100644 --- a/src/game/partyNavigationCatalog.test.ts +++ b/src/game/partyNavigationCatalog.test.ts @@ -84,7 +84,7 @@ describe("party navigation fleet safety", () => { const enabled = []; const fallback = []; - expect(DUNGEON_DEFINITIONS).toHaveLength(85); + expect(DUNGEON_DEFINITIONS).toHaveLength(86); for (const dungeon of DUNGEON_DEFINITIONS) { expect(dungeonCanEnter(dungeon.id), dungeon.id).toBe(true); const navigation = dungeon.assets.navigation; @@ -119,7 +119,7 @@ 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(31); + expect(enabled).toHaveLength(32); expect(fallback).toHaveLength(54); }); diff --git a/src/game/partyStore.test.ts b/src/game/partyStore.test.ts index aeb8306f..2a7ca6dd 100644 --- a/src/game/partyStore.test.ts +++ b/src/game/partyStore.test.ts @@ -86,6 +86,42 @@ describe("dungeon party composition", () => { .toEqual(["hunter:marksmanship", "rogue:assassination"]); }); + it("fills only missing roles around every real player in an online five-player party", () => { + const options = { + occupiedHumanRoles: ["healer", "damage"] as const, + excludedPlayerSpecializationIds: ["priest:discipline", "mage:fire"], + allowedCompanionSpecializationIds: [ + "warrior:protection", + "priest:discipline", + "hunter:marksmanship", + "mage:fire", + "rogue:assassination", + ], + }; + const leaderParty = createDungeonParty("shared-activity", 20, "healer", 4, "wow", options); + const memberParty = createDungeonParty("shared-activity", 20, "damage", 4, "wow", options); + + expect(memberParty).toEqual(leaderParty); + expect(leaderParty).toHaveLength(3); + expect(leaderParty.map((member) => member.role).sort()).toEqual(["damage", "damage", "tank"]); + expect(leaderParty.every((member) => options.allowedCompanionSpecializationIds.includes(member.specializationId!))).toBe(true); + }); + + it("uses two damage NPCs when three real players already cover tank, healer, and damage", () => { + const party = createDungeonParty("three-player-activity", 20, "tank", 3, "wow", { + occupiedHumanRoles: ["tank", "healer", "damage"], + allowedCompanionSpecializationIds: [ + "warrior:protection", + "priest:discipline", + "hunter:marksmanship", + "mage:fire", + "rogue:assassination", + ], + }); + expect(party).toHaveLength(2); + expect(party.every((member) => member.role === "damage")).toBe(true); + }); + it("keeps RoM generation unchanged when WoW readiness is restrictive", () => { const baseline = createDungeonParty("rom-readiness", 40, "damage", 5, "rom"); const filtered = createDungeonParty("rom-readiness", 40, "damage", 5, "rom", { diff --git a/src/game/partyStore.ts b/src/game/partyStore.ts index bb0332c2..54963b07 100644 --- a/src/game/partyStore.ts +++ b/src/game/partyStore.ts @@ -124,6 +124,10 @@ export interface CompanionPartyGenerationOptions { readonly readinessPolicyRevision?: number; readonly includeBetaSpecs?: boolean; readonly excludedPlayerSpecializationId?: string | null; + readonly excludedPlayerSpecializationIds?: readonly string[]; + readonly allowedCompanionSpecializationIds?: readonly string[]; + /** All real-player roles in a shared party, including the local player. */ + readonly occupiedHumanRoles?: readonly PartyRole[]; } function companionTemplateFor(specializationId: string): PartyTemplate { @@ -146,9 +150,18 @@ function wowTemplates( seats: number, ): readonly PartyTemplate[] { const policy = options.readinessPolicy ?? DEFAULT_COMPANION_SPEC_READINESS_POLICY; - const definitions = companionSpecializationsForPolicy(policy, options.includeBetaSpecs === true, role); - const withoutPlayer = definitions.filter((definition) => definition.id !== options.excludedPlayerSpecializationId); - const candidates = withoutPlayer.length >= seats ? withoutPlayer : definitions; + const allowed = options.allowedCompanionSpecializationIds + ? new Set(options.allowedCompanionSpecializationIds) + : null; + const definitions = allowed + ? WOW_COMPANION_SPECIALIZATIONS.filter((definition) => definition.role === role && allowed.has(definition.id)) + : companionSpecializationsForPolicy(policy, options.includeBetaSpecs === true, role); + const excluded = new Set([ + ...(options.excludedPlayerSpecializationIds ?? []), + ...(options.excludedPlayerSpecializationId ? [options.excludedPlayerSpecializationId] : []), + ]); + const withoutPlayers = definitions.filter((definition) => !excluded.has(definition.id)); + const candidates = withoutPlayers.length >= seats ? withoutPlayers : definitions; return candidates.map((definition) => companionTemplateFor(definition.id)); } @@ -410,35 +423,40 @@ export function createDungeonParty( options: CompanionPartyGenerationOptions = {}, ): readonly PartyMember[] { const random = { value: hashSeed(seed || "healer-man") }; - const tankTemplates = categoryId === "rom" ? ROM_TANK_TEMPLATES : wowTemplates("tank", options, 1); - const healerTemplates = categoryId === "rom" ? ROM_HEALER_TEMPLATES : wowTemplates("healer", options, 1); - const damageSeats = playerRole === "damage" ? 2 : 3; - const damageTemplates = categoryId === "rom" ? ROM_DAMAGE_TEMPLATES : wowTemplates("damage", options, damageSeats); - const tank = tankTemplates[Math.floor(nextRandom(random) * tankTemplates.length)] ?? tankTemplates[0]; - const damagePool = [...damageTemplates]; - for (let index = damagePool.length - 1; index > 0; index -= 1) { - const swapIndex = Math.floor(nextRandom(random) * (index + 1)); - [damagePool[index], damagePool[swapIndex]] = [damagePool[swapIndex], damagePool[index]]; + const npcSeats = Math.max(0, partySize - 1); + const humanRoleCounts: Record = { tank: 0, healer: 0, damage: 0 }; + for (const role of options.occupiedHumanRoles?.length ? options.occupiedHumanRoles : [playerRole]) { + humanRoleCounts[role] += 1; } - const healer = healerTemplates[Math.floor(nextRandom(random) * healerTemplates.length)] - ?? healerTemplates[0]; - if (playerRole === "tank") { - return [ - createMember(healer, "healer", level, 0, seed), - ...damagePool.slice(0, 3).map((template, index) => createMember(template, "damage", level, index + 1, seed)), - ].slice(0, Math.max(0, partySize - 1)); - } - if (playerRole === "damage") { - return [ - createMember(tank, "tank", level, 0, seed), - createMember(healer, "healer", level, 1, seed), - ...damagePool.slice(0, 2).map((template, index) => createMember(template, "damage", level, index + 2, seed)), - ].slice(0, Math.max(0, partySize - 1)); - } - return [ - createMember(tank, "tank", level, 0, seed), - ...damagePool.slice(0, 3).map((template, index) => createMember(template, "damage", level, index + 1, seed)), - ].slice(0, Math.max(0, partySize - 1)); + const npcRoles = (["tank", "healer", "damage", "damage", "damage"] as const).filter((role) => { + if (humanRoleCounts[role] <= 0) return true; + humanRoleCounts[role] -= 1; + return false; + }).slice(0, npcSeats); + const seatsByRole: Record = { tank: 0, healer: 0, damage: 0 }; + for (const role of npcRoles) seatsByRole[role] += 1; + const sourceForRole = (role: PartyRole): readonly PartyTemplate[] => { + if (categoryId === "rom") { + return role === "tank" ? ROM_TANK_TEMPLATES : role === "healer" ? ROM_HEALER_TEMPLATES : ROM_DAMAGE_TEMPLATES; + } + return wowTemplates(role, options, seatsByRole[role]); + }; + const pools = Object.fromEntries((["tank", "healer", "damage"] as const).map((role) => { + const pool = [...sourceForRole(role)]; + for (let index = pool.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(nextRandom(random) * (index + 1)); + [pool[index], pool[swapIndex]] = [pool[swapIndex], pool[index]]; + } + return [role, pool] as const; + })) as Record; + const usedByRole: Record = { tank: 0, healer: 0, damage: 0 }; + return npcRoles.map((role, index) => { + const pool = pools[role]; + if (!pool.length) throw new Error(`No ${role} companion is available for this party.`); + const template = pool[usedByRole[role] % pool.length]; + usedByRole[role] += 1; + return createMember(template, role, level, index, seed); + }); } /** Builds a complete deterministic NPC group for GM spectator simulations. */ diff --git a/src/game/randomizedBiteSizedStockades.test.ts b/src/game/randomizedBiteSizedStockades.test.ts new file mode 100644 index 00000000..fdf79f0c --- /dev/null +++ b/src/game/randomizedBiteSizedStockades.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, + STORMWIND_STOCKADE_DUNGEON_ID, +} from "./dungeonContentSource"; +import { DUNGEON_REGISTRY, dungeonDefinitionById } from "./dungeonRegistry"; +import { activateDungeonSession } from "./dungeonSession"; +import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "./dungeons/biteSizedStockades"; +import { + createRandomizedBiteSizedStockadesDefinition, + randomizedBiteSizedCreatureCatalog, +} from "./dungeons/randomizedBiteSizedStockades"; + +const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID]; + +describe("Randomized Bite-Sized Stockades", () => { + it("keeps source geometry and both existing Stockades definitions immutable", () => { + const originalSpawns = source.staticSpawns.map((spawn) => ({ ...spawn })); + const randomized = createRandomizedBiteSizedStockadesDefinition(source, "random-layout-a"); + + expect(randomized.id).toBe(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID); + expect(randomized.navigationLinks).toEqual(source.navigationLinks); + expect(randomized.traversalRamps).toEqual(source.traversalRamps); + expect(randomized.roamingPacks).toEqual([]); + expect(randomized.serverGameObjects.filter((object) => object.id.startsWith("bite-sized-stockades-"))).toHaveLength(2); + expect(source.staticSpawns).toEqual(originalSpawns); + }); + + it("rerolls every ordinary slot from the complete placed Stockades mob pool", () => { + const catalog = randomizedBiteSizedCreatureCatalog(source); + const mobIds = catalog.filter((candidate) => candidate.kind === "mob").map((candidate) => candidate.entityId); + const observed = new Set(); + for (let index = 0; index < 2_000; index += 1) { + const definition = createRandomizedBiteSizedStockadesDefinition(source, `mob-roll-${index}`); + const bossId = definition.bosses[0].id; + for (const spawn of definition.staticSpawns.filter((candidate) => candidate.id !== bossId)) { + expect(mobIds).toContain(spawn.entityId); + observed.add(spawn.entityId); + } + } + expect([...observed].sort()).toEqual([...mobIds].sort()); + }); + + it("reconstructs the same party lineup from a run id and can select every boss", () => { + const first = createRandomizedBiteSizedStockadesDefinition(source, "shared-run"); + const second = createRandomizedBiteSizedStockadesDefinition(source, "shared-run"); + expect(second.staticSpawns).toEqual(first.staticSpawns); + expect(second.bosses).toEqual(first.bosses); + + const bosses = new Set(); + for (let index = 0; index < 1_000; index += 1) { + bosses.add(createRandomizedBiteSizedStockadesDefinition(source, `boss-roll-${index}`).bosses[0].id); + } + expect([...bosses].sort()).toEqual(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => candidate.sourceSpawnId).sort()); + + expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true); + expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(first.staticSpawns); + }); +}); diff --git a/src/game/wowDungeonEnvironment.test.ts b/src/game/wowDungeonEnvironment.test.ts index 15437be1..588139c3 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 { BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource"; +import { isDerivedStockadesDungeonId } from "./dungeonContentSource"; describe("fleet-wide WoW dungeon environment policy", () => { const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => ( - dungeon.expansion !== "runewaker" && dungeon.id !== BITE_SIZED_STOCKADES_DUNGEON_ID + dungeon.expansion !== "runewaker" && !isDerivedStockadesDungeonId(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/dungeonMaterialAudit.test.ts b/src/scene/dungeonMaterialAudit.test.ts index 36371a9c..d8a74fdb 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 { BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource"; +import { isDerivedStockadesDungeonId } 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" && definition.id !== BITE_SIZED_STOCKADES_DUNGEON_ID + definition.expansion !== "runewaker" && !isDerivedStockadesDungeonId(definition.id) )); describe("shipped WoW dungeon material policies", () => { diff --git a/src/styles.css b/src/styles.css index 742536ce..e6a2a72f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4335,3 +4335,57 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); } .dungeon-sim-admin__header { grid-template-columns: 1fr; } .dungeon-sim-admin__replays article { flex-direction: column; } } + +.randomized-bite-sized-screen, +.randomized-creature-readiness { min-height: 100vh; padding: clamp(18px, 3vw, 42px); } +.randomized-bite-sized-screen__header, +.randomized-creature-readiness__header { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 18px; margin-bottom: 24px; } +.randomized-bite-sized-screen__header > div, +.randomized-creature-readiness__header > div:first-of-type { display: grid; gap: 3px; } +.randomized-creature-readiness__header > div:last-child { display: flex; gap: 8px; } +.randomized-bite-sized-screen__layout { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(330px, .65fr); gap: 22px; } +.randomized-bite-sized-screen__hero, +.randomized-bite-sized-screen__launch, +.randomized-creature-readiness__catalog, +.randomized-creature-readiness__details { border: 1px solid rgba(202, 160, 74, .3); border-radius: 14px; background: linear-gradient(145deg, rgba(21, 27, 32, .94), rgba(8, 15, 21, .92)); padding: clamp(18px, 3vw, 30px); } +.randomized-bite-sized-screen h1 { margin: 4px 0 10px; font-size: clamp(2.2rem, 5vw, 4.7rem); line-height: .95; color: #f3d58e; } +.randomized-bite-sized-screen__odds, +.randomized-creature-readiness__summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 24px 0; } +.randomized-bite-sized-screen__odds > div, +.randomized-creature-readiness__summary > div { display: grid; gap: 4px; padding: 14px; border: 1px solid rgba(213, 176, 94, .24); background: rgba(78, 57, 25, .22); } +.randomized-bite-sized-screen__odds strong, +.randomized-creature-readiness__summary strong { color: #f0cb75; font-size: 1.6rem; } +.randomized-bite-sized-screen__odds span, +.randomized-creature-readiness__summary span { color: #b8c6cb; font-size: .75rem; text-transform: uppercase; } +.randomized-bite-sized-screen__roles { display: grid; gap: 10px; margin-top: 18px; } + +.randomized-creature-readiness__summary { max-width: 700px; } +.randomized-creature-readiness__layout { display: grid; grid-template-columns: minmax(420px, 1.1fr) minmax(320px, .9fr); gap: 22px; } +.randomized-creature-readiness__filters { display: grid; grid-template-columns: 1fr 150px 180px; gap: 10px; margin-bottom: 14px; } +.randomized-creature-readiness label { display: grid; gap: 4px; color: #9eb4bd; font-size: .78rem; } +.randomized-creature-readiness input, +.randomized-creature-readiness select { width: 100%; color: #eef8fb; background: #0c1c27; border: 1px solid #3b5665; border-radius: 6px; padding: 9px; } +.randomized-creature-readiness__list { display: grid; gap: 7px; max-height: 58vh; overflow: auto; } +.randomized-creature-readiness__list > button { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: #eaf4f7; border: 1px solid rgba(125, 158, 170, .22); border-radius: 7px; background: rgba(15, 31, 40, .72); padding: 10px; text-align: left; } +.randomized-creature-readiness__list > button.is-active { border-color: #d3ad59; background: rgba(105, 76, 25, .3); } +.randomized-creature-readiness__list span { display: grid; gap: 3px; } +.randomized-creature-readiness__list small { color: #8da7b1; } +.creature-readiness { flex: 0 0 auto; border: 1px solid currentColor; border-radius: 999px; padding: 3px 7px; font-size: .68rem; font-style: normal; text-transform: uppercase; } +.creature-readiness--ready { color: #8de2a5; } +.creature-readiness--needs-testing { color: #f0cb75; } +.creature-readiness--not-ready { color: #ff9e91; } +.randomized-creature-readiness__details dl { display: grid; gap: 8px; } +.randomized-creature-readiness__details dl div { display: grid; grid-template-columns: 110px 1fr; gap: 10px; } +.randomized-creature-readiness__details dt { color: #839da7; } +.randomized-creature-readiness__details dd { margin: 0; overflow-wrap: anywhere; } +.randomized-creature-readiness__status-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0; } + +@media (max-width: 900px) { + .randomized-bite-sized-screen__header, + .randomized-creature-readiness__header, + .randomized-bite-sized-screen__layout, + .randomized-creature-readiness__layout { grid-template-columns: 1fr; } + .randomized-creature-readiness__filters { grid-template-columns: 1fr; } + .randomized-bite-sized-screen__odds, + .randomized-creature-readiness__summary { grid-template-columns: 1fr; } +} diff --git a/src/ui/CompanionPoolControls.tsx b/src/ui/CompanionPoolControls.tsx index 2420e130..3eea668a 100644 --- a/src/ui/CompanionPoolControls.tsx +++ b/src/ui/CompanionPoolControls.tsx @@ -1,19 +1,20 @@ import { useCompanionPartyPreferences } from "../game/companionPartyPreferences"; import { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore"; -export function CompanionPoolControls({ onOpenReadiness, compact = false }: { +export function CompanionPoolControls({ onOpenReadiness, compact = false, readyOnly = false }: { readonly onOpenReadiness: () => void; readonly compact?: boolean; + readonly readyOnly?: boolean; }) { const policy = useCompanionSpecReadinessStore((state) => state.config); const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs); const setIncludeBetaSpecs = useCompanionPartyPreferences((state) => state.setIncludeBetaSpecs); const betaCount = Object.values(policy.statuses).filter((status) => status === "beta").length; return
- }
; } diff --git a/src/ui/DungeonSimulationAdminScreen.tsx b/src/ui/DungeonSimulationAdminScreen.tsx index c90a0d42..6bd9bbc9 100644 --- a/src/ui/DungeonSimulationAdminScreen.tsx +++ b/src/ui/DungeonSimulationAdminScreen.tsx @@ -25,6 +25,7 @@ import type { import { BrandMark, FrontSurface } from "./FrontSurface"; import { CompanionPoolControls } from "./CompanionPoolControls"; import { CompanionReadinessScreen } from "./CompanionReadinessScreen"; +import { RandomizedCreatureReadinessScreen } from "./RandomizedCreatureReadinessScreen"; const COOLDOWN_MECHANICS = BOSS_TESTING_CATALOG.flatMap((boss) => { const entity = requireDungeonDefinition(boss.dungeonId).entities[boss.entityId]; @@ -62,6 +63,7 @@ export function DungeonSimulationAdminScreen() { const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [readinessOpen, setReadinessOpen] = useState(false); + const [creatureReadinessOpen, setCreatureReadinessOpen] = useState(false); const [selectedCooldownMechanic, setSelectedCooldownMechanic] = useState(COOLDOWN_MECHANICS[0]?.key ?? ""); const cooldownDraft = useCompanionCooldownPolicyStore((state) => state.draft); const cooldownRevision = useCompanionCooldownPolicyStore((state) => state.revision); @@ -212,6 +214,7 @@ export function DungeonSimulationAdminScreen() { }; if (readinessOpen) return setReadinessOpen(false)} />; + if (creatureReadinessOpen) return setCreatureReadinessOpen(false)} />; return ( @@ -219,6 +222,7 @@ export function DungeonSimulationAdminScreen() {

Game Master tools

Dungeon route simulations

+
diff --git a/src/ui/MainMenuScreen.tsx b/src/ui/MainMenuScreen.tsx index c67677ee..5d30eb75 100644 --- a/src/ui/MainMenuScreen.tsx +++ b/src/ui/MainMenuScreen.tsx @@ -16,6 +16,7 @@ const BossCardCollectionScreen = lazy(() => import("./BossCardCollection")); const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen }))); const BalanceLabScreen = lazy(() => import("./BalanceLabScreen").then((module) => ({ default: module.BalanceLabScreen }))); const CompanionReadinessScreen = lazy(() => import("./CompanionReadinessScreen").then((module) => ({ default: module.CompanionReadinessScreen }))); +const RandomizedBiteSizedScreen = lazy(() => import("./RandomizedBiteSizedScreen").then((module) => ({ default: module.RandomizedBiteSizedScreen }))); const GAME_MODES = [ { @@ -25,6 +26,13 @@ const GAME_MODES = [ description: "Choose an expedition and lead your party into the depths.", available: true, }, + { + id: "randomized-bite-sized", + number: "02", + name: "Randomized Bite-Sized", + description: "Run a short dungeon with every mob and the final boss rerolled.", + available: true, + }, ...(SHELVED_MODES_ENABLED ? [{ id: "manastorms" as const, number: "02", @@ -75,24 +83,27 @@ export function MainMenuScreen() { const [bossTestingOpen, setBossTestingOpen] = useState(false); const [balanceLabOpen, setBalanceLabOpen] = useState(false); const [readinessOpen, setReadinessOpen] = useState(false); + const [randomizedBiteSizedOpen, setRandomizedBiteSizedOpen] = useState(false); const [editor, setEditor] = useState(null); if (editor) return setEditor(null)} />; if (bossTestingOpen) return Opening Boss Testing…

}> setBossTestingOpen(false)} />
; if (balanceLabOpen) return Opening Balance Lab…

}> setBalanceLabOpen(false)} />
; if (readinessOpen) return Opening Companion Readiness…

}> setReadinessOpen(false)} />
; + if (randomizedBiteSizedOpen) return Opening Randomized Bite-Sized…

}> setRandomizedBiteSizedOpen(false)} />
; if (cardsOpen) return Opening collection log…

}> setCardsOpen(false)} />
; if (contentManagerOpen) return setContentManagerOpen(false)} />; if (reportsOpen) return setReportsOpen(false)} />; - return setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenEditor={setEditor} />; + return setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenRandomizedBiteSized={() => setRandomizedBiteSizedOpen(true)} onOpenEditor={setEditor} />; } -function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenReadiness, onOpenEditor }: { +function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenReadiness, onOpenRandomizedBiteSized, onOpenEditor }: { readonly onOpenDownloads: () => void; readonly onOpenReports: () => void; readonly onOpenCards: () => void; readonly onOpenBossTesting: () => void; readonly onOpenBalanceLab: () => void; readonly onOpenReadiness: () => void; + readonly onOpenRandomizedBiteSized: () => void; readonly onOpenEditor: (editor: MainMenuEditor) => void; }) { const activeCharacter = useShellStore((state) => state.activeCharacter); @@ -113,12 +124,17 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO { id: "mode-dungeons", run: openDungeons, - neighbors: { right: "mode-manastorms", down: "back-to-roster" }, + neighbors: { right: "mode-randomized-bite-sized", down: "back-to-roster" }, + }, + { + id: "mode-randomized-bite-sized", + run: onOpenRandomizedBiteSized, + neighbors: { left: "mode-dungeons", right: SHELVED_MODES_ENABLED ? "mode-manastorms" : "mode-downloads", down: "back-to-roster" }, }, ...(SHELVED_MODES_ENABLED ? [{ id: "mode-manastorms", run: openManastorms, - neighbors: { left: "mode-dungeons", down: "back-to-roster" }, + neighbors: { left: "mode-randomized-bite-sized", down: "back-to-roster" }, }, { id: "mode-arenas", run: openArenas, @@ -148,7 +164,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO { id: "mode-cards", run: onOpenCards }, { id: "mode-boss-testing", run: onOpenBossTesting }, { id: "mode-companion-readiness", run: onOpenReadiness }, - ], [character, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]); + ], [character, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]); const controller = useMenuController(actions, { initialId: "mode-dungeons", onBack: returnToCharacters, @@ -292,6 +308,8 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO ? openArenas : mode.id === "downloads" ? onOpenDownloads + : mode.id === "randomized-bite-sized" + ? onOpenRandomizedBiteSized : openDungeons} > diff --git a/src/ui/OnlineGroupPanel.tsx b/src/ui/OnlineGroupPanel.tsx index 354263d0..496a28e0 100644 --- a/src/ui/OnlineGroupPanel.tsx +++ b/src/ui/OnlineGroupPanel.tsx @@ -9,7 +9,7 @@ import { type PartyRole, } from "../game/partyRoles"; -export function OnlineGroupPanel() { +export function OnlineGroupPanel({ forceAiFill = false }: { readonly forceAiFill?: boolean }) { const [username, setUsername] = useState(""); const [panelOpen, setPanelOpen] = useState(false); const session = useShellStore((state) => state.session); @@ -121,12 +121,14 @@ export function OnlineGroupPanel() { )} - {leader && group && !group.activity && ( + {leader && group && !group.activity && (forceAiFill ? ( +

Every ready real player enters together; Ready NPCs fill the remaining seats to five.

+ ) : ( - )} + ))} {(!group || leader) && (
diff --git a/src/ui/RandomizedBiteSizedScreen.tsx b/src/ui/RandomizedBiteSizedScreen.tsx new file mode 100644 index 00000000..3e61b0b0 --- /dev/null +++ b/src/ui/RandomizedBiteSizedScreen.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import { selectedCharacter, useShellStore } from "../app/shellStore"; +import { installDungeonShellRuntime } from "../app/dungeonShellRuntime"; +import { useOnlineGroupStore } from "../app/onlineGroupStore"; +import { specializationById } from "../game/specializationCatalog"; +import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore"; +import { dungeonContentIsUsable, installContentPack } from "../content/contentManager"; +import { useContentManagerSnapshot } from "../content/useContentManager"; +import { + RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, + STORMWIND_STOCKADE_DUNGEON_ID, + dungeonContentPackId, +} from "../game/dungeonContentSource"; +import { randomizedBiteSizedCreatureCatalog } from "../game/dungeons/randomizedBiteSizedStockades"; +import { dungeonById } from "../game/dungeonCatalog"; +import { requireDungeonDefinition } from "../game/dungeonRegistry"; +import { BrandMark, FrontSurface } from "./FrontSurface"; +import { DungeonDifficultySlider } from "./DungeonDifficultySlider"; +import { OnlineGroupPanel } from "./OnlineGroupPanel"; +import { CompanionPoolControls } from "./CompanionPoolControls"; +import { CompanionReadinessScreen } from "./CompanionReadinessScreen"; + +installDungeonShellRuntime(); + +const DUNGEON = dungeonById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID); +const CANDIDATES = randomizedBiteSizedCreatureCatalog(requireDungeonDefinition(STORMWIND_STOCKADE_DUNGEON_ID)); + +export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => void }) { + useContentManagerSnapshot(); + const activeCharacter = useShellStore((state) => state.activeCharacter); + const rosterCharacter = useShellStore(selectedCharacter); + const enterDungeon = useShellStore((state) => state.enterDungeon); + const session = useShellStore((state) => state.session); + const notice = useShellStore((state) => state.notice); + const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group); + const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember); + const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity); + const [bossDifficulty, setBossDifficulty] = useState(1); + const [downloading, setDownloading] = useState(false); + const [error, setError] = useState(""); + const [readinessOpen, setReadinessOpen] = useState(false); + const character = activeCharacter ?? rosterCharacter; + const specialization = specializationById(character?.specializationId); + const roles = character + ? character.categoryId === "rom" ? partyRolesForClass(character.classId) : specialization ? [specialization.role] : [] + : []; + const installed = dungeonContentIsUsable(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID); + const mobCount = CANDIDATES.filter((candidate) => candidate.kind === "mob").length; + const bossCount = CANDIDATES.filter((candidate) => candidate.kind === "boss").length; + + const launch = async (role: PartyRole) => { + if (!character || !DUNGEON?.available) return; + if (!onlineGroup || !session || session.kind !== "account") { + enterDungeon(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, role, { + difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId, + bossDifficulty, + freshRun: true, + }); + return; + } + if (!await updateOnlineMember(session, character, role)) return; + if (onlineGroup.leaderAccountId !== session.ownerId) { + useShellStore.getState().setNotice(`Ready as ${PARTY_ROLE_LABELS[role]}. Waiting for the group leader to start.`); + return; + } + await startOnlineActivity(session, { + type: "dungeon", + contentId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, + difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId, + bossDifficulty, + partySize: 5, + fillWithAi: true, + includeBetaSpecs: false, + }); + }; + + const install = async () => { + setDownloading(true); setError(""); + try { + await installContentPack(dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)); + if (!dungeonContentIsUsable(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)) { + throw new Error("The Stockades content pack could not be verified."); + } + } catch (caught) { + setError(caught instanceof Error ? caught.message : "The content download failed."); + } finally { + setDownloading(false); + } + }; + + if (readinessOpen) return setReadinessOpen(false)} />; + + return ( + +
+ +
Main menu / Randomized Bite-SizedCellblock Shuffle
+ +
+
+
+

A different cellblock every run

+

Randomized Bite-Sized

+

Fight through the short Stockades route with every ordinary enemy slot rerolled, then face one randomly selected warden.

+
+
{mobCount}mob types in the shuffle
+
{bossCount}possible final bosses
+
1boss per run
+
+

The random lineup is locked to the run seed, so online party members and resumed sessions see the same enemies.

+ + setReadinessOpen(true)} readyOnly /> +
+
+

Run setup

+

{DUNGEON?.title ?? "Cellblock Shuffle"}

+ undefined} /> + {!installed ? ( + + ) : roles.length ? ( +
+ Enter as + {roles.map((role) => )} +
+ ) :

Choose a specialization in your Class Hall before entering.

} + {!DUNGEON?.available &&

This randomized route is not enterable in the current content build.

} + {(error || notice) &&

{error || notice}

} +
+
+
+ ); +} diff --git a/src/ui/RandomizedCreatureReadinessScreen.tsx b/src/ui/RandomizedCreatureReadinessScreen.tsx new file mode 100644 index 00000000..bde2c6c9 --- /dev/null +++ b/src/ui/RandomizedCreatureReadinessScreen.tsx @@ -0,0 +1,104 @@ +import { useEffect, useMemo, useState } from "react"; +import { + CREATURE_READINESS_LABELS, + CREATURE_READINESS_STATUSES, + type CreatureReadinessStatus, + useRandomizedCreatureReadiness, +} from "../app/randomizedCreatureReadiness"; +import { useShellStore } from "../app/shellStore"; +import { STORMWIND_STOCKADE_DUNGEON_ID } from "../game/dungeonContentSource"; +import { + randomizedBiteSizedCreatureCatalog, + type RandomizedCreatureKind, +} from "../game/dungeons/randomizedBiteSizedStockades"; +import { requireDungeonDefinition } from "../game/dungeonRegistry"; +import { BrandMark, FrontSurface } from "./FrontSurface"; + +const CREATURES = randomizedBiteSizedCreatureCatalog( + requireDungeonDefinition(STORMWIND_STOCKADE_DUNGEON_ID), +); + +type KindFilter = "all" | RandomizedCreatureKind; +type StatusFilter = "all" | CreatureReadinessStatus; + +export function RandomizedCreatureReadinessScreen({ onClose }: { readonly onClose: () => void }) { + const session = useShellStore((state) => state.session); + const reviews = useRandomizedCreatureReadiness((state) => state.reviews); + const loading = useRandomizedCreatureReadiness((state) => state.loading); + const error = useRandomizedCreatureReadiness((state) => state.error); + const refresh = useRandomizedCreatureReadiness((state) => state.refresh); + const save = useRandomizedCreatureReadiness((state) => state.save); + const [query, setQuery] = useState(""); + const [kind, setKind] = useState("all"); + const [status, setStatus] = useState("all"); + const [selectedKey, setSelectedKey] = useState(CREATURES[0]?.key ?? ""); + + useEffect(() => { void refresh(); }, [refresh]); + const statusOf = (key: string): CreatureReadinessStatus => reviews[key]?.status ?? "needs-testing"; + const filtered = useMemo(() => CREATURES.filter((creature) => ( + (kind === "all" || creature.kind === kind) + && (status === "all" || statusOf(creature.key) === status) + && (!query.trim() || creature.name.toLowerCase().includes(query.trim().toLowerCase()) + || String(creature.serverEntry ?? "").includes(query.trim())) + )), [kind, query, reviews, status]); + const selected = CREATURES.find((creature) => creature.key === selectedKey) ?? filtered[0] ?? null; + const counts = Object.fromEntries(CREATURE_READINESS_STATUSES.map((candidate) => [ + candidate, + CREATURES.filter((creature) => statusOf(creature.key) === candidate).length, + ])) as Record; + + return ( + +
+ +

Game Master tools

Randomized creature readiness

+
+ + +
+
+
+ {CREATURE_READINESS_STATUSES.map((candidate) => ( +
{counts[candidate]}{CREATURE_READINESS_LABELS[candidate]}
+ ))} +
+
+
+
+ + + +
+
+ {filtered.map((creature) => ( + + ))} + {!filtered.length &&

No creatures match these filters.

} +
+
+
+ {selected ? <> +

{selected.kind === "boss" ? "Boss candidate" : "Mob candidate"}

+

{selected.name}

+
Source
{selected.sourceDungeonTitle}
Entity
{selected.entityId}
Server entry
{selected.serverEntry ?? "Unknown"}
+

Current status: {CREATURE_READINESS_LABELS[statusOf(selected.key)]}

+
+ {CREATURE_READINESS_STATUSES.map((candidate) => ( + + ))} +
+ {reviews[selected.key] && Last reviewed by {reviews[selected.key].reviewedBy} on {new Date(reviews[selected.key].reviewedAt).toLocaleString()}.} + :

Select a creature to review it.

} + {error &&

{error}

} +
+
+
+ ); +}