forked from phenom/healer-man
Release Healer Man 0.1.58
This commit is contained in:
1 parent
14e105642c
commit
246b4832b0
38 files changed
+952
-93
No files matched your search
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.57",
|
||||
"versionCode": 1057
|
||||
"versionName": "0.1.58",
|
||||
"versionCode": 1058
|
||||
}
|
||||
+14
-3
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -30,7 +30,9 @@ interface OnlineGroupState {
|
||||
leave: (session: PlayerSession) => Promise<boolean>;
|
||||
remove: (session: PlayerSession, accountId: string) => Promise<boolean>;
|
||||
updateMember: (session: PlayerSession, character: CharacterProfile, role: PartyRole) => Promise<boolean>;
|
||||
startActivity: (session: PlayerSession, input: Omit<StartOnlineGroupActivityInput, "fillWithAi">) => Promise<boolean>;
|
||||
startActivity: (session: PlayerSession, input: Omit<StartOnlineGroupActivityInput, "fillWithAi"> & {
|
||||
readonly fillWithAi?: boolean;
|
||||
}) => Promise<boolean>;
|
||||
clearActivity: (session: PlayerSession) => Promise<boolean>;
|
||||
setFillWithAi: (fillWithAi: boolean) => void;
|
||||
consumeActivity: (activityId: string) => void;
|
||||
@@ -127,7 +129,10 @@ export const useOnlineGroupStore = create<OnlineGroupState>((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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<CreatureReadinessStatus, string>> = {
|
||||
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<Record<string, RandomizedCreatureReview>>;
|
||||
readonly loading: boolean;
|
||||
readonly error: string;
|
||||
refresh: () => Promise<void>;
|
||||
save: (
|
||||
session: PlayerSession,
|
||||
creature: RandomizedBiteSizedCreature,
|
||||
status: CreatureReadinessStatus,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useRandomizedCreatureReadiness = create<RandomizedCreatureReadinessState>((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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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<DungeonCatalogEntry>((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. */
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>> = 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;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Record<string, DungeonDefinition>> = 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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ export const DUNGEON_RELEASE_STAGE_LABELS: Readonly<Record<DungeonReleaseStage,
|
||||
};
|
||||
|
||||
export function dungeonReleaseStage(id: string): DungeonReleaseStage {
|
||||
return id === "stormwind-stockade" || id === "bite-sized-stormwind-stockade" ? "beta" : "alpha";
|
||||
return id === "stormwind-stockade"
|
||||
|| id === "bite-sized-stormwind-stockade"
|
||||
|| id === "randomized-bite-sized-stormwind-stockade"
|
||||
? "beta"
|
||||
: "alpha";
|
||||
}
|
||||
|
||||
export function compareDungeonReleaseOrder(
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { DungeonDefinition } from "../dungeonTypes";
|
||||
import type { PopulationEntityDefinition } from "../mobPopulation";
|
||||
import {
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
STORMWIND_STOCKADE_DUNGEON_ID,
|
||||
} from "../dungeonContentSource";
|
||||
import {
|
||||
BITE_SIZED_STOCKADES_BOSS_CANDIDATES,
|
||||
createBiteSizedStockadesDefinition,
|
||||
} from "./biteSizedStockades";
|
||||
|
||||
export type RandomizedCreatureKind = "mob" | "boss";
|
||||
|
||||
export interface RandomizedBiteSizedCreature {
|
||||
readonly key: string;
|
||||
readonly sourceDungeonId: string;
|
||||
readonly sourceDungeonTitle: string;
|
||||
readonly entityId: string;
|
||||
readonly name: string;
|
||||
readonly kind: RandomizedCreatureKind;
|
||||
readonly serverEntry: number | null;
|
||||
}
|
||||
|
||||
function seedHash(seed: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < seed.length; index += 1) {
|
||||
hash ^= seed.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function placedEntityIds(source: DungeonDefinition): ReadonlySet<string> {
|
||||
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<Record<string, PopulationEntityDefinition>>;
|
||||
|
||||
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.",
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
+49
-31
@@ -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<PartyRole, number> = { 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<PartyRole, number> = { 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<PartyRole, PartyTemplate[]>;
|
||||
const usedByRole: Record<PartyRole, number> = { 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. */
|
||||
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 <div className={`companion-pool-controls ${compact ? "companion-pool-controls--compact" : ""}`}>
|
||||
<label title={betaCount ? "Allow certified beta specializations in random NPC seats." : "No beta specs available"}>
|
||||
{readyOnly ? <span><strong>Ready NPCs only</strong><small>Open seats use the published Ready specialization pool.</small></span> : <label title={betaCount ? "Allow certified beta specializations in random NPC seats." : "No beta specs available"}>
|
||||
<input type="checkbox" checked={includeBetaSpecs && betaCount > 0} disabled={betaCount === 0} onChange={(event) => setIncludeBetaSpecs(event.target.checked)} />
|
||||
<span><strong>Include Beta Specs</strong><small>{betaCount ? `${betaCount} beta ${betaCount === 1 ? "spec" : "specs"} available` : "No beta specs available"}</small></span>
|
||||
</label>
|
||||
</label>}
|
||||
<button className="text-action" type="button" onClick={onOpenReadiness}>View companion readiness</button>
|
||||
</div>;
|
||||
}
|
||||
@@ -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 <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
|
||||
if (creatureReadinessOpen) return <RandomizedCreatureReadinessScreen onClose={() => setCreatureReadinessOpen(false)} />;
|
||||
|
||||
return (
|
||||
<FrontSurface className="dungeon-sim-admin" ariaLabel="Dungeon simulation route review">
|
||||
@@ -219,6 +222,7 @@ export function DungeonSimulationAdminScreen() {
|
||||
<BrandMark />
|
||||
<div><p className="eyebrow">Game Master tools</p><h1>Dungeon route simulations</h1></div>
|
||||
<div className="dungeon-sim-admin__header-actions">
|
||||
<button className="front-button" type="button" onClick={() => setCreatureReadinessOpen(true)}>Creature readiness</button>
|
||||
<button className="front-button" type="button" onClick={() => void refresh()} disabled={busy}>Refresh</button>
|
||||
<button className="front-button" type="button" onClick={returnToMainMenu}>Main menu</button>
|
||||
</div>
|
||||
|
||||
@@ -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<MainMenuEditor | null>(null);
|
||||
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
|
||||
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
|
||||
if (balanceLabOpen) return <Suspense fallback={<p role="status">Opening Balance Lab…</p>}><BalanceLabScreen onClose={() => setBalanceLabOpen(false)} /></Suspense>;
|
||||
if (readinessOpen) return <Suspense fallback={<p role="status">Opening Companion Readiness…</p>}><CompanionReadinessScreen onClose={() => setReadinessOpen(false)} /></Suspense>;
|
||||
if (randomizedBiteSizedOpen) return <Suspense fallback={<p role="status">Opening Randomized Bite-Sized…</p>}><RandomizedBiteSizedScreen onClose={() => setRandomizedBiteSizedOpen(false)} /></Suspense>;
|
||||
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
|
||||
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
|
||||
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenEditor={setEditor} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => 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}
|
||||
>
|
||||
<b aria-hidden="true">{mode.number}</b>
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leader && group && !group.activity && (
|
||||
{leader && group && !group.activity && (forceAiFill ? (
|
||||
<p className="front-notice">Every ready real player enters together; Ready NPCs fill the remaining seats to five.</p>
|
||||
) : (
|
||||
<label className="online-group-ai-toggle">
|
||||
<input type="checkbox" checked={fillWithAi} onChange={(event) => setFillWithAi(event.target.checked)} />
|
||||
<span><strong>Fill open slots with AI</strong><small>Turn off to enter with only grouped players.</small></span>
|
||||
</label>
|
||||
)}
|
||||
))}
|
||||
|
||||
{(!group || leader) && (
|
||||
<form className="online-group-invite-form" onSubmit={submitInvite}>
|
||||
|
||||
@@ -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 <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
|
||||
|
||||
return (
|
||||
<FrontSurface className="randomized-bite-sized-screen" ariaLabel="Randomized Bite-Sized dungeon">
|
||||
<header className="randomized-bite-sized-screen__header">
|
||||
<BrandMark compact />
|
||||
<div><span>Main menu / Randomized Bite-Sized</span><strong>Cellblock Shuffle</strong></div>
|
||||
<button className="front-button" type="button" onClick={onClose}>Main menu</button>
|
||||
</header>
|
||||
<main className="randomized-bite-sized-screen__layout">
|
||||
<section className="randomized-bite-sized-screen__hero">
|
||||
<p className="front-kicker">A different cellblock every run</p>
|
||||
<h1>Randomized Bite-Sized</h1>
|
||||
<p>Fight through the short Stockades route with every ordinary enemy slot rerolled, then face one randomly selected warden.</p>
|
||||
<div className="randomized-bite-sized-screen__odds">
|
||||
<div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div>
|
||||
<div><strong>{bossCount}</strong><span>possible final bosses</span></div>
|
||||
<div><strong>1</strong><span>boss per run</span></div>
|
||||
</div>
|
||||
<p className="front-notice">The random lineup is locked to the run seed, so online party members and resumed sessions see the same enemies.</p>
|
||||
<OnlineGroupPanel forceAiFill />
|
||||
<CompanionPoolControls onOpenReadiness={() => setReadinessOpen(true)} readyOnly />
|
||||
</section>
|
||||
<section className="randomized-bite-sized-screen__launch">
|
||||
<p className="front-kicker">Run setup</p>
|
||||
<h2>{DUNGEON?.title ?? "Cellblock Shuffle"}</h2>
|
||||
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} />
|
||||
{!installed ? (
|
||||
<button className="front-button front-button--primary" type="button" disabled={downloading} onClick={() => void install()}>{downloading ? "Installing…" : "Install Stockades content"}</button>
|
||||
) : roles.length ? (
|
||||
<div className="randomized-bite-sized-screen__roles">
|
||||
<strong>Enter as</strong>
|
||||
{roles.map((role) => <button className="front-button front-button--primary" key={role} type="button" disabled={!DUNGEON?.available} onClick={() => void launch(role)}>{PARTY_ROLE_LABELS[role]}</button>)}
|
||||
</div>
|
||||
) : <p className="front-notice">Choose a specialization in your Class Hall before entering.</p>}
|
||||
{!DUNGEON?.available && <p className="front-notice">This randomized route is not enterable in the current content build.</p>}
|
||||
{(error || notice) && <p className="front-notice" role="status">{error || notice}</p>}
|
||||
</section>
|
||||
</main>
|
||||
</FrontSurface>
|
||||
);
|
||||
}
|
||||
@@ -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<KindFilter>("all");
|
||||
const [status, setStatus] = useState<StatusFilter>("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<CreatureReadinessStatus, number>;
|
||||
|
||||
return (
|
||||
<FrontSurface className="randomized-creature-readiness" ariaLabel="Randomized creature readiness">
|
||||
<header className="randomized-creature-readiness__header">
|
||||
<BrandMark compact />
|
||||
<div><p className="eyebrow">Game Master tools</p><h1>Randomized creature readiness</h1></div>
|
||||
<div>
|
||||
<button className="front-button" type="button" disabled={loading} onClick={() => void refresh()}>Refresh</button>
|
||||
<button className="front-button" type="button" onClick={onClose}>Back</button>
|
||||
</div>
|
||||
</header>
|
||||
<section className="randomized-creature-readiness__summary" aria-label="Readiness counts">
|
||||
{CREATURE_READINESS_STATUSES.map((candidate) => (
|
||||
<div key={candidate}><strong>{counts[candidate]}</strong><span>{CREATURE_READINESS_LABELS[candidate]}</span></div>
|
||||
))}
|
||||
</section>
|
||||
<main className="randomized-creature-readiness__layout">
|
||||
<section className="randomized-creature-readiness__catalog">
|
||||
<div className="randomized-creature-readiness__filters">
|
||||
<label><span>Search</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Name or server entry" /></label>
|
||||
<label><span>Kind</span><select value={kind} onChange={(event) => setKind(event.target.value as KindFilter)}>
|
||||
<option value="all">All</option><option value="mob">Mobs</option><option value="boss">Bosses</option>
|
||||
</select></label>
|
||||
<label><span>Status</span><select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
|
||||
<option value="all">All</option>{CREATURE_READINESS_STATUSES.map((candidate) => <option key={candidate} value={candidate}>{CREATURE_READINESS_LABELS[candidate]}</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
<div className="randomized-creature-readiness__list">
|
||||
{filtered.map((creature) => (
|
||||
<button key={creature.key} className={creature.key === selected?.key ? "is-active" : ""} type="button" onClick={() => setSelectedKey(creature.key)}>
|
||||
<span><strong>{creature.name}</strong><small>{creature.kind} · entry {creature.serverEntry ?? "unknown"}</small></span>
|
||||
<em className={`creature-readiness creature-readiness--${statusOf(creature.key)}`}>{CREATURE_READINESS_LABELS[statusOf(creature.key)]}</em>
|
||||
</button>
|
||||
))}
|
||||
{!filtered.length && <p className="front-notice">No creatures match these filters.</p>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="randomized-creature-readiness__details">
|
||||
{selected ? <>
|
||||
<p className="front-kicker">{selected.kind === "boss" ? "Boss candidate" : "Mob candidate"}</p>
|
||||
<h2>{selected.name}</h2>
|
||||
<dl><div><dt>Source</dt><dd>{selected.sourceDungeonTitle}</dd></div><div><dt>Entity</dt><dd>{selected.entityId}</dd></div><div><dt>Server entry</dt><dd>{selected.serverEntry ?? "Unknown"}</dd></div></dl>
|
||||
<p>Current status: <strong>{CREATURE_READINESS_LABELS[statusOf(selected.key)]}</strong></p>
|
||||
<div className="randomized-creature-readiness__status-actions">
|
||||
{CREATURE_READINESS_STATUSES.map((candidate) => (
|
||||
<button className={`front-button ${candidate === statusOf(selected.key) ? "front-button--primary" : ""}`} key={candidate} type="button" disabled={loading || !session} onClick={() => session && void save(session, selected, candidate)}>{CREATURE_READINESS_LABELS[candidate]}</button>
|
||||
))}
|
||||
</div>
|
||||
{reviews[selected.key] && <small>Last reviewed by {reviews[selected.key].reviewedBy} on {new Date(reviews[selected.key].reviewedAt).toLocaleString()}.</small>}
|
||||
</> : <p className="front-notice">Select a creature to review it.</p>}
|
||||
{error && <p className="front-notice" role="alert">{error}</p>}
|
||||
</section>
|
||||
</main>
|
||||
</FrontSurface>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user