Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 13:40:23 -04:00
1 parent cc3b2fa4d5
commit daa417ec17
46 files changed
+1334 -172

No files matched your search

+5 -2
View File
@@ -72,7 +72,8 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
if (!Array.isArray(input.killedBossIds) || input.killedBossIds.length > 100
|| input.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) fail("Provide bosses from this dungeon and difficulty.");
const killedBossIds = [...new Set(input.killedBossIds)].sort();
if (!killedBossIds.includes(difficulty.finalBossId)) fail("The final boss must be defeated before recording a run.");
const finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId];
if (!finalBossIds.some((id) => killedBossIds.includes(id))) fail("The final boss must be defeated before recording a run.");
if (!Array.isArray(input.party) || input.party.length < 1 || input.party.length > 5) fail("Provide the run's party.");
const party = input.party.map((member) => {
if (!member || typeof member !== "object" || typeof member.name !== "string" || !member.name.trim()
@@ -84,7 +85,9 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
role: member.role, npc: member.npc,
...(member.secondaryClassId ? { secondaryClassId: member.secondaryClassId } : {}) };
});
const fullClear = difficulty.bossIds.every((id) => killedBossIds.includes(id));
const fullClear = difficulty.finalBossIds
? difficulty.finalBossIds.some((id) => killedBossIds.includes(id))
: difficulty.bossIds.every((id) => killedBossIds.includes(id));
const run = { id: input.id, dungeonId: dungeon.id, difficultyId, durationMs: input.durationMs,
...(bossDifficulty > 0 ? { bossDifficulty } : {}),
finishedAt: input.finishedAt, killedBossIds, party, fullClear };
+11
View File
@@ -58,6 +58,17 @@ 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);
}
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-"));
const { server } = createGameServer({ dataDir: directory, enableShelvedModes: false });
+5 -1
View File
@@ -2,7 +2,11 @@ import { randomUUID } from "node:crypto";
import availability from "../src/game/generated/dungeonAvailability.json" with { type: "json" };
import policy from "../shared/dungeon-report-policy.json" with { type: "json" };
export const REPORT_DUNGEON_IDS = new Set(["forsaken-abbey", ...Object.keys(availability.enabled)]);
export const REPORT_DUNGEON_IDS = new Set([
"bite-sized-stormwind-stockade",
"forsaken-abbey",
...Object.keys(availability.enabled),
]);
export function createDungeonReportRepository(database, { isGameMaster, ErrorType }) {
database.exec(`
+6 -1
View File
@@ -26,7 +26,12 @@ test("validates comments and dungeon IDs and stamps new reports as Submitted", (
assert.equal(report.status, "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.deepEqual(database.listDungeonReports().openCounts, { "forsaken-abbey": 1, "stormwind-stockade": 1 });
assert.equal(database.submitDungeonReport(null, { dungeonId: "bite-sized-stormwind-stockade", comment: "Wall issue" }).status, "Submitted");
assert.deepEqual(database.listDungeonReports().openCounts, {
"bite-sized-stormwind-stockade": 1,
"forsaken-abbey": 1,
"stormwind-stockade": 1,
});
}));
test("enforces GM updates, counts all open statuses, supports reopening, and prevents stale changes", () => withDatabase(async (database) => {
+4 -2
View File
@@ -39,7 +39,7 @@ describe("online group activity entry", () => {
await enterOnlineGroupActivity({ ...DUNGEON_ACTIVITY, difficultyId: "heroic", bossDifficulty: 9 },
"damage", vi.fn(), dependencies);
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "damage", {
difficultyId: "heroic", bossDifficulty: 9, freshRun: true,
difficultyId: "heroic", bossDifficulty: 9, freshRun: true, runId: DUNGEON_ACTIVITY.id,
});
});
it("leaves a dungeon activity unconsumed when dungeon entry fails", async () => {
@@ -54,7 +54,9 @@ describe("online group activity entry", () => {
)).resolves.toBe(false);
expect(dependencies.installDungeonRuntime).toHaveBeenCalledOnce();
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "healer");
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "healer", {
difficultyId: undefined, bossDifficulty: undefined, freshRun: true, runId: DUNGEON_ACTIVITY.id,
});
expect(consumeActivity).not.toHaveBeenCalled();
});
+6 -5
View File
@@ -48,11 +48,12 @@ export async function enterOnlineGroupActivity(
let entered: boolean;
if (activity.type === "dungeon") {
await dependencies.installDungeonRuntime();
entered = activity.bossDifficulty === undefined && activity.difficultyId === undefined
? dependencies.enterDungeon(activity.contentId, role)
: dependencies.enterDungeon(activity.contentId, role, {
difficultyId: activity.difficultyId, bossDifficulty: activity.bossDifficulty, freshRun: true,
});
entered = dependencies.enterDungeon(activity.contentId, role, {
difficultyId: activity.difficultyId,
bossDifficulty: activity.bossDifficulty,
freshRun: true,
runId: activity.id,
});
} else {
if (!SHELVED_MODES_ENABLED) return false;
await dependencies.installManastormRuntime();
+19
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { localDungeonLeaderboard, localDungeonRunPlacement, saveLocalDungeonRun, submitDungeonRun } from "./dungeonLeaderboardClient";
import type { DungeonRunRecord } from "../game/dungeonRunTypes";
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import { leaderboardFinalBossIds } from "../game/dungeonLeaderboardRules";
const dungeon = catalog.dungeons.find((entry) => entry.id === "deadmines")!;
const record = (id: string, durationMs: number, fullClear = true): DungeonRunRecord => ({
@@ -41,6 +42,24 @@ describe("offline dungeon leaderboard", () => {
expect(page.entries).toHaveLength(7);
expect(localDungeonRunPlacement(record("offline-24", 3400))).toEqual({ finalRank: 27, finalTotal: 27, fullRank: 26, fullTotal: 26 });
});
it("treats any rolled bite-sized Stockades boss as a complete run", () => {
const biteSized = catalog.dungeons.find((entry) => entry.id === "bite-sized-stormwind-stockade")!;
const difficulty = biteSized.difficulties[0];
const rolledBossId = leaderboardFinalBossIds(difficulty).at(-1)!;
const run: DungeonRunRecord = {
id: "bite-sized-local-run",
dungeonId: biteSized.id,
difficultyId: difficulty.id,
durationMs: 4321,
finishedAt: 100000,
party: [{ name: "Tester", classId: "priest", level: 20, role: "healer", npc: false }],
killedBossIds: [rolledBossId],
fullClear: true,
};
saveLocalDungeonRun(run);
expect(localDungeonLeaderboard(biteSized.id, difficulty.id, "full").entries)
.toEqual([expect.objectContaining({ id: run.id, killedBossIds: [rolledBossId], fullClear: true })]);
});
it("retains the local time after a failed upload and uses the same run id when retried", async () => {
const run = record("offline-retry-test", 4321);
saveLocalDungeonRun(run);
+4 -2
View File
@@ -3,6 +3,7 @@ import type { PlayerSession } from "./types";
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
import { isLeaderboardFullClear, leaderboardFinalBossIds } from "../game/dungeonLeaderboardRules";
const STORAGE_KEY = "healer-man:dungeon-runs:v1";
const listeners = new Set<() => void>();
@@ -23,11 +24,12 @@ function localRuns(): DungeonRunRecord[] {
const progress = normalizeDungeonRunProgress({ ...value, startedAt: value.finishedAt - value.durationMs, timed: true });
const dungeon = catalog.dungeons.find((entry) => entry.id === progress?.dungeonId);
const difficulty = dungeon?.difficulties.find((entry) => entry.id === progress?.difficultyId);
const finalBossIds = difficulty ? leaderboardFinalBossIds(difficulty) : [];
if (!progress || progress.finishedAt === null || progress.durationMs === null || !dungeon
|| !difficulty || !progress.killedBossIds.includes(difficulty.finalBossId)
|| !difficulty || !finalBossIds.some((id) => progress.killedBossIds.includes(id))
|| progress.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) return [];
return [{ ...value, ...progress, durationMs: progress.durationMs, finishedAt: progress.finishedAt,
fullClear: difficulty.bossIds.every((id) => progress.killedBossIds.includes(id)) }];
fullClear: isLeaderboardFullClear(difficulty, progress.killedBossIds) }];
});
} catch { /* In-memory results remain available if storage is disabled. */ }
return memory;
+1
View File
@@ -68,6 +68,7 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
encounterSnapshots: resume?.encounters,
runProgress: resume?.run,
legacyResume: Boolean(resume && resume.run?.bossDifficulty === undefined),
runId: settings?.runId,
})) {
useShellStore.setState({
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
+1
View File
@@ -8,6 +8,7 @@ export interface DungeonEntrySettings {
readonly difficultyId?: string;
readonly bossDifficulty?: number;
readonly freshRun?: boolean;
readonly runId?: string;
}
let installedRuntime: DungeonShellRuntime | null = null;
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
currentLoginInputSnapshot,
DEFAULT_LOGIN_KEY_ID,
nextLoginKeyId,
useLoginInputStore,
} from "./loginInputStore";
describe("controller login input", () => {
beforeEach(() => useLoginInputStore.getState().reset());
it("starts on the username field and the first alphabet key", () => {
expect(useLoginInputStore.getState()).toMatchObject({
activeField: "username",
selectedKeyId: DEFAULT_LOGIN_KEY_ID,
keyboardMode: "lower",
});
});
it("moves geometrically between character and wide action keys", () => {
expect(nextLoginKeyId("lower", "char-3-1", "down")).toBe("control-field");
expect(nextLoginKeyId("lower", "control-field", "down")).toBe("action-login");
expect(nextLoginKeyId("lower", "action-login", "up")).toBe("control-field");
});
it("types valid usernames, switches fields, and keeps passwords private in snapshots", () => {
const input = useLoginInputStore.getState();
input.pressKey("char-1-0");
input.pressKey("char-1-1");
input.pressKey("char-2-9");
expect(useLoginInputStore.getState().username).toBe("qw_");
input.pressKey("control-done");
expect(useLoginInputStore.getState().activeField).toBe("password");
input.pressKey("char-1-0");
input.pressKey("control-space");
expect(useLoginInputStore.getState().password).toBe("q ");
expect(currentLoginInputSnapshot()).toMatchObject({ username: "qw_", passwordLength: 2 });
expect(currentLoginInputSnapshot()).not.toHaveProperty("password");
});
it("cycles case and symbols while preserving the selected key position", () => {
const input = useLoginInputStore.getState();
input.pressKey("control-mode");
expect(useLoginInputStore.getState()).toMatchObject({ keyboardMode: "upper", selectedKeyId: "control-mode" });
input.pressKey("control-mode");
expect(useLoginInputStore.getState().keyboardMode).toBe("symbols");
input.pressKey("char-1-1");
expect(useLoginInputStore.getState().username).toBe("");
input.pressKey("control-field");
input.pressKey("char-1-1");
expect(useLoginInputStore.getState().password).toBe("@");
});
it("turns the three final-row keys into consumable requests", () => {
const input = useLoginInputStore.getState();
input.pressKey("action-login");
const request = useLoginInputStore.getState().request;
expect(request).toMatchObject({ action: "login" });
input.consumeRequest(request!.id);
expect(useLoginInputStore.getState().request).toBeNull();
input.pressKey("action-offline");
expect(useLoginInputStore.getState().request?.action).toBe("offline");
});
});
+224
View File
@@ -0,0 +1,224 @@
import { create } from "zustand";
export type LoginField = "username" | "password";
export type LoginKeyboardMode = "lower" | "upper" | "symbols";
export type LoginRequestAction = "login" | "create" | "offline";
export type LoginKeyboardDirection = "up" | "down" | "left" | "right";
export interface LoginKeyDefinition {
id: string;
label: string;
ariaLabel: string;
kind: "character" | "mode" | "field" | "space" | "backspace" | "clear" | "done" | LoginRequestAction;
value?: string;
span?: number;
}
export interface LoginInputSnapshot {
username: string;
passwordLength: number;
activeField: LoginField;
selectedKeyId: string;
keyboardMode: LoginKeyboardMode;
busy: LoginRequestAction | null;
}
interface LoginRequest {
id: number;
action: LoginRequestAction;
}
interface LoginInputState {
username: string;
password: string;
activeField: LoginField;
selectedKeyId: string;
keyboardMode: LoginKeyboardMode;
busy: LoginRequestAction | null;
request: LoginRequest | null;
requestId: number;
setUsername: (value: string) => void;
setPassword: (value: string) => void;
setActiveField: (field: LoginField) => void;
selectKey: (keyId: string) => void;
moveSelection: (direction: LoginKeyboardDirection) => void;
pressKey: (keyId: string) => void;
pressSelected: () => void;
consumeRequest: (id: number) => void;
setBusy: (busy: LoginRequestAction | null) => void;
reset: () => void;
}
export const DEFAULT_LOGIN_KEY_ID = "char-1-0";
const characterLayouts: Record<LoginKeyboardMode, readonly (readonly string[])[]> = {
lower: [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l", "_"],
["z", "x", "c", "v", "b", "n", "m", ".", "-"],
],
upper: [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L", "_"],
["Z", "X", "C", "V", "B", "N", "M", ".", "-"],
],
symbols: [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"],
["{", "}", "[", "]", "<", ">", "=", "+", "/", "\\"],
["~", "`", ";", ":", "'", "\"", ",", ".", "?"],
],
};
function characterKey(value: string, row: number, column: number): LoginKeyDefinition {
return {
id: `char-${row}-${column}`,
label: value,
ariaLabel: value === " " ? "Space" : value,
kind: "character",
value,
};
}
export function loginKeyboardRows(mode: LoginKeyboardMode): readonly (readonly LoginKeyDefinition[])[] {
const rows = characterLayouts[mode];
return [
rows[0].map((value, column) => characterKey(value, 0, column)),
rows[1].map((value, column) => characterKey(value, 1, column)),
rows[2].map((value, column) => characterKey(value, 2, column)),
[
{ id: "control-mode", label: mode === "lower" ? "⇧" : mode === "upper" ? "#+=" : "abc", ariaLabel: "Change keyboard mode", kind: "mode" },
...rows[3].map((value, column) => characterKey(value, 3, column + 1)),
],
[
{ id: "control-field", label: "Field", ariaLabel: "Switch login field", kind: "field", span: 2 },
{ id: "control-space", label: "Space", ariaLabel: "Space", kind: "space", span: 3 },
{ id: "control-backspace", label: "⌫", ariaLabel: "Backspace", kind: "backspace", span: 2 },
{ id: "control-clear", label: "Clear", ariaLabel: "Clear active field", kind: "clear", span: 2 },
{ id: "control-done", label: "Done", ariaLabel: "Finish active field", kind: "done" },
],
[
{ id: "action-login", label: "Enter world", ariaLabel: "Log in and enter world", kind: "login", span: 4 },
{ id: "action-create", label: "Create", ariaLabel: "Create account", kind: "create", span: 3 },
{ id: "action-offline", label: "Offline", ariaLabel: "Play offline", kind: "offline", span: 3 },
],
];
}
function keyPosition(rows: readonly (readonly LoginKeyDefinition[])[], keyId: string) {
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
let column = 0;
for (let keyIndex = 0; keyIndex < rows[rowIndex].length; keyIndex += 1) {
const key = rows[rowIndex][keyIndex];
const span = key.span ?? 1;
if (key.id === keyId) return { rowIndex, keyIndex, center: column + span / 2 };
column += span;
}
}
return null;
}
export function nextLoginKeyId(
mode: LoginKeyboardMode,
selectedKeyId: string,
direction: LoginKeyboardDirection,
): string {
const rows = loginKeyboardRows(mode);
const position = keyPosition(rows, selectedKeyId) ?? keyPosition(rows, DEFAULT_LOGIN_KEY_ID)!;
const row = rows[position.rowIndex];
if (direction === "left" || direction === "right") {
const delta = direction === "left" ? -1 : 1;
return row[(position.keyIndex + delta + row.length) % row.length].id;
}
const rowDelta = direction === "up" ? -1 : 1;
const targetRowIndex = (position.rowIndex + rowDelta + rows.length) % rows.length;
const targetRow = rows[targetRowIndex];
let column = 0;
return targetRow.reduce((closest, key) => {
const center = column + (key.span ?? 1) / 2;
column += key.span ?? 1;
return Math.abs(center - position.center) < Math.abs(closest.center - position.center)
? { id: key.id, center }
: closest;
}, { id: targetRow[0].id, center: (targetRow[0].span ?? 1) / 2 }).id;
}
function findLoginKey(mode: LoginKeyboardMode, keyId: string): LoginKeyDefinition | null {
return loginKeyboardRows(mode).flat().find((key) => key.id === keyId) ?? null;
}
function nextMode(mode: LoginKeyboardMode): LoginKeyboardMode {
if (mode === "lower") return "upper";
if (mode === "upper") return "symbols";
return "lower";
}
const initialState = {
username: "",
password: "",
activeField: "username" as LoginField,
selectedKeyId: DEFAULT_LOGIN_KEY_ID,
keyboardMode: "lower" as LoginKeyboardMode,
busy: null,
request: null,
requestId: 0,
};
export const useLoginInputStore = create<LoginInputState>((set, get) => ({
...initialState,
setUsername: (username) => set({ username: username.slice(0, 20), activeField: "username" }),
setPassword: (password) => set({ password: password.slice(0, 128), activeField: "password" }),
setActiveField: (activeField) => set({ activeField }),
selectKey: (selectedKeyId) => {
if (findLoginKey(get().keyboardMode, selectedKeyId)) set({ selectedKeyId });
},
moveSelection: (direction) => set((state) => ({
selectedKeyId: nextLoginKeyId(state.keyboardMode, state.selectedKeyId, direction),
})),
pressKey: (keyId) => {
const state = get();
const key = findLoginKey(state.keyboardMode, keyId);
if (!key) return;
set({ selectedKeyId: key.id });
if (key.kind === "character" && key.value) {
if (state.activeField === "username") {
if (/^[A-Za-z0-9_]$/.test(key.value) && state.username.length < 20) set({ username: state.username + key.value });
} else if (state.password.length < 128) set({ password: state.password + key.value });
return;
}
if (key.kind === "mode") set({ keyboardMode: nextMode(state.keyboardMode) });
else if (key.kind === "field") set({ activeField: state.activeField === "username" ? "password" : "username" });
else if (key.kind === "space" && state.activeField === "password" && state.password.length < 128) set({ password: `${state.password} ` });
else if (key.kind === "backspace") {
if (state.activeField === "username") set({ username: state.username.slice(0, -1) });
else set({ password: state.password.slice(0, -1) });
} else if (key.kind === "clear") {
if (state.activeField === "username") set({ username: "" });
else set({ password: "" });
} else if (key.kind === "done") {
if (state.activeField === "username") set({ activeField: "password" });
else set({ selectedKeyId: "action-login" });
} else if (["login", "create", "offline"].includes(key.kind) && !state.busy) {
const requestId = state.requestId + 1;
set({ requestId, request: { id: requestId, action: key.kind as LoginRequestAction } });
}
},
pressSelected: () => get().pressKey(get().selectedKeyId),
consumeRequest: (id) => set((state) => state.request?.id === id ? { request: null } : {}),
setBusy: (busy) => set({ busy }),
reset: () => set(initialState),
}));
export function currentLoginInputSnapshot(): LoginInputSnapshot {
const state = useLoginInputStore.getState();
return {
username: state.username,
passwordLength: state.password.length,
activeField: state.activeField,
selectedKeyId: state.selectedKeyId,
keyboardMode: state.keyboardMode,
busy: state.busy,
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

+2 -1
View File
@@ -9,6 +9,7 @@ import {
} from "./contentManifest";
import { NativeContentStorage } from "./nativeContentStorage";
import contentPolicy from "../../shared/content-policy.json";
import { dungeonContentPackId } from "../game/dungeonContentSource";
export const CONTENT_STATE_KEY = "healer-man.content-state.v2";
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
@@ -367,7 +368,7 @@ export function contentPackIsUsable(packId: string): boolean {
/** Vite serves the complete source asset tree in development; shipped builds require an installed pack. */
export function dungeonContentIsUsable(dungeonId: string): boolean {
return import.meta.env.DEV || contentPackIsUsable(`dungeon:${dungeonId}`);
return import.meta.env.DEV || contentPackIsUsable(dungeonContentPackId(dungeonId));
}
export function contentPackHasUpdate(packId: string): boolean {
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import { BITE_SIZED_STOCKADES_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID } from "./dungeonContentSource";
import { DUNGEON_REGISTRY, dungeonDefinitionById } from "./dungeonRegistry";
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
import { useDungeonRunStore } from "./dungeonRunStore";
import {
BITE_SIZED_STOCKADES_BOSS_CANDIDATES,
createBiteSizedStockadesDefinition,
} from "./dungeons/biteSizedStockades";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID];
describe("bite-sized Stormwind Stockades", () => {
it("creates a separate central-corridor dungeon without changing Stockades", () => {
const originalBossIds = source.bosses.map((boss) => boss.id);
const originalSpawnCount = source.staticSpawns.length;
const biteSized = createBiteSizedStockadesDefinition(source, "run-a");
expect(biteSized.id).toBe(BITE_SIZED_STOCKADES_DUNGEON_ID);
expect(biteSized).not.toBe(source);
expect(source.bosses.map((boss) => boss.id)).toEqual(originalBossIds);
expect(source.staticSpawns).toHaveLength(originalSpawnCount);
expect(source.bosses).toHaveLength(5);
expect(source.serverGameObjects.some((object) => object.id.startsWith("bite-sized-"))).toBe(false);
const bossId = biteSized.bosses[0].id;
expect(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => boss.sourceSpawnId)).toContain(bossId);
expect(biteSized.objectiveOrder).toEqual(biteSized.bosses);
expect(biteSized.staticSpawns.filter((spawn) => spawn.id === bossId)).toHaveLength(1);
expect(biteSized.staticSpawns.every((spawn) => Math.abs(spawn.position[2]) <= 15)).toBe(true);
expect(biteSized.navigationLinks.map((link) => link.id)).toEqual(["entrance-to-central-stairs"]);
expect(biteSized.traversalRamps?.map((ramp) => ramp.id)).toEqual(["entrance-stair-ramp"]);
});
it("seals both side wings with visible movement and sight blockers", () => {
const biteSized = createBiteSizedStockadesDefinition(source, "run-b");
const walls = biteSized.serverGameObjects.filter((object) => object.id.startsWith("bite-sized-stockades-"));
expect(walls.map((wall) => wall.id).sort()).toEqual([
"bite-sized-stockades-north-wall",
"bite-sized-stockades-south-wall",
]);
for (const wall of walls) {
expect(() => validateDungeonWorldObject(wall)).not.toThrow();
expect(wall.states).toEqual([expect.objectContaining({
id: "sealed",
visible: true,
blocksMovement: true,
blocksSight: true,
})]);
}
});
it("chooses all five source bosses deterministically across fresh run ids", () => {
const selected = new Set<string>();
for (let index = 0; index < 1_000; index += 1) {
selected.add(createBiteSizedStockadesDefinition(source, `fresh-run-${index}`).bosses[0].name);
}
expect([...selected].sort()).toEqual(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => boss.name).sort());
const first = createBiteSizedStockadesDefinition(source, "same-run");
const second = createBiteSizedStockadesDefinition(source, "same-run");
expect(second.bosses[0]).toEqual(first.bosses[0]);
expect(second.staticSpawns.find((spawn) => spawn.id === second.bosses[0].id)?.entityId)
.toBe(first.staticSpawns.find((spawn) => spawn.id === first.bosses[0].id)?.entityId);
});
it("uses the run identity for fresh, restarted, and shared sessions", () => {
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "run-a" })).toBe(true);
const firstBoss = dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name;
expect(useDungeonRunStore.getState().progress?.id).toBe("run-a");
expect(restartActiveDungeonSession()).toBe(true);
expect(dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name).toBe(firstBoss);
expect(activateDungeonSession(BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "run-b" })).toBe(true);
expect(useDungeonRunStore.getState().progress?.id).toBe("run-b");
expect(dungeonDefinitionById(BITE_SIZED_STOCKADES_DUNGEON_ID)!.bosses[0].name).not.toBe(firstBoss);
});
it("completes the one-boss run only through its stable objective", () => {
const biteSized = createBiteSizedStockadesDefinition(source, "completion-run");
const bossId = biteSized.bosses[0].id;
const encounter = biteSized.encounters![0];
expect(() => validateDungeonEncounterDefinition(encounter)).not.toThrow();
const snapshot = createDungeonEncounterSnapshot(encounter, 1_000);
const reduced = reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now: 2_000,
facts: {
registeredEntityIds: [bossId],
deadEntityIds: [bossId],
},
}).snapshot;
expect(reduced.completedObjectiveIds).toEqual([bossId]);
expect(reduced.flags["dungeon-complete"]).toBe(true);
expect(reduced.phase).toBe("complete");
});
});
+4 -2
View File
@@ -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(84);
expect(DUNGEON_DEFINITIONS).toHaveLength(85);
expect(generated.definitions.map((dungeon) => dungeon.id))
.toEqual(campaign.dungeons.map((dungeon) => dungeon.slug));
for (const dungeon of generated.definitions) {
@@ -327,7 +327,9 @@ describe("generated dungeon campaign catalog", () => {
});
it("compiles every sourced WoW gameobject row with stable source provenance", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
));
const objects = wowDungeons.flatMap((dungeon) => dungeon.serverGameObjects);
const sourceRows = objects.filter((object) => (
object.source?.templateId !== undefined && object.source?.spawnId !== undefined
+11 -8
View File
@@ -11,8 +11,8 @@ import { compareDungeonReleaseOrder } from "./dungeonReleaseStage";
import availability from "./generated/dungeonAvailability.json";
describe("dungeon catalog", () => {
it("lists the complete generated campaign with Stormwind Stockades in Beta first", () => {
expect(DUNGEON_CATALOG).toHaveLength(84);
it("lists the complete campaign plus the bite-sized Stockades variant", () => {
expect(DUNGEON_CATALOG).toHaveLength(85);
expect(DUNGEON_CATALOG[0]).toMatchObject({
id: "stormwind-stockade",
releaseStage: "beta",
@@ -37,8 +37,8 @@ describe("dungeon catalog", () => {
});
});
it("separates WoW and RoM dungeons with all RuneWaker imports under RoM", () => {
expect(DUNGEON_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom"]);
it("separates WoW, RoM, and bite-sized dungeons", () => {
expect(DUNGEON_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom", "bite-sized"]);
expect(dungeonsByCategory("rom").map((dungeon) => dungeon.id).sort()).toEqual([
"forsaken-abbey",
...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => definition.id),
@@ -47,6 +47,8 @@ describe("dungeon catalog", () => {
expect.objectContaining({ id: "forsaken-abbey" }),
);
expect(dungeonsByCategory("rom")).toHaveLength(30);
expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id))
.toEqual(["bite-sized-stormwind-stockade"]);
});
it("keeps stable unique ids and resolves only catalogued dungeons", () => {
@@ -64,10 +66,11 @@ describe("dungeon catalog", () => {
}
});
it("keeps only Stockades in Beta and alphabetizes each release group", () => {
it("keeps both Stockades experiences in Beta and alphabetizes each release group", () => {
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id))
.toEqual(["stormwind-stockade"]);
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.id !== "stormwind-stockade").every((dungeon) => dungeon.releaseStage === "alpha"))
.toEqual(["stormwind-stockade", "bite-sized-stormwind-stockade"]);
expect(DUNGEON_CATALOG.filter((dungeon) => !dungeon.id.includes("stormwind-stockade"))
.every((dungeon) => dungeon.releaseStage === "alpha"))
.toBe(true);
for (const category of DUNGEON_CATEGORIES) {
const dungeons = dungeonsByCategory(category.id);
@@ -87,6 +90,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(["forsaken-abbey", ...Object.keys(availability.enabled)].sort());
.toEqual(["bite-sized-stormwind-stockade", "forsaken-abbey", ...Object.keys(availability.enabled)].sort());
});
});
+8 -3
View File
@@ -1,8 +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";
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
const SOURCE_DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
const DUNGEON_CATEGORY_IDS = ["wow", "rom", "bite-sized"] as const;
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
export interface DungeonCategory {
@@ -12,10 +14,11 @@ export interface DungeonCategory {
}
export const DUNGEON_CATEGORIES: readonly DungeonCategory[] = Object.freeze([
...DUNGEON_CATEGORY_IDS.map((id) => {
...SOURCE_DUNGEON_CATEGORY_IDS.map((id) => {
const category = contentCategoryById(id);
return { id, label: category.label, fullName: category.name };
}),
{ id: "bite-sized", label: "Bite-Sized", fullName: "Bite-Sized Dungeons" },
]);
export interface DungeonCatalogEntry {
@@ -36,7 +39,9 @@ export interface DungeonCatalogEntry {
export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
id: definition.id as DungeonId,
category: definition.expansion === "runewaker" ? "rom" : "wow",
category: definition.id === BITE_SIZED_STOCKADES_DUNGEON_ID
? "bite-sized"
: definition.expansion === "runewaker" ? "rom" : "wow",
title: definition.title,
mapId: definition.mapId,
entranceName: definition.entrance.name,
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentPackId,
dungeonContentSourceId,
} from "./dungeonContentSource";
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(dungeonContentPackId("deadmines")).toBe("dungeon:deadmines");
});
});
+15
View File
@@ -0,0 +1,15 @@
export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade";
export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade";
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
[BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_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;
}
export function dungeonContentPackId(dungeonId: string): string {
return `dungeon:${dungeonContentSourceId(dungeonId)}`;
}
+23 -5
View File
@@ -3,21 +3,39 @@ 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 { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "./dungeons/biteSizedStockades";
const stockades = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === STORMWIND_STOCKADE_DUNGEON_ID)!;
const biteSizedBosses = BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => (
stockades.bosses.find((boss) => boss.id === candidate.sourceSpawnId)!
));
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: finalDungeonBoss(dungeon)?.id ?? "",
bosses: dungeon.bosses.map((boss) => ({ id: boss.id, name: boss.name })),
finalBossId: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
? biteSizedBosses[0].id
: finalDungeonBoss(dungeon)?.id ?? "",
bosses: (dungeon.id === BITE_SIZED_STOCKADES_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: dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id),
finalBossId: finalDungeonBoss(dungeon, difficulty.id)?.id ?? "" })),
bossIds: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
? biteSizedBosses.map((boss) => boss.id)
: dungeonBossesForDifficulty(dungeon, difficulty.id).map((boss) => boss.id),
finalBossId: dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
? biteSizedBosses[0].id
: finalDungeonBoss(dungeon, difficulty.id)?.id ?? "",
...(dungeon.id === BITE_SIZED_STOCKADES_DUNGEON_ID
? { finalBossIds: biteSizedBosses.map((boss) => boss.id) }
: {}),
})),
defaultDifficultyId: dungeon.defaultDifficultyId,
})).sort((a, b) => a.id.localeCompare(b.id)),
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
};
expect(catalog.dungeons).toHaveLength(84);
expect(catalog.dungeons).toHaveLength(85);
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
View File
@@ -0,0 +1,18 @@
export interface DungeonLeaderboardDifficultyRule {
readonly bossIds: readonly string[];
readonly finalBossId: string;
readonly finalBossIds?: readonly string[];
}
export function leaderboardFinalBossIds(rule: DungeonLeaderboardDifficultyRule): readonly string[] {
return rule.finalBossIds?.length ? rule.finalBossIds : [rule.finalBossId];
}
export function isLeaderboardFullClear(
rule: DungeonLeaderboardDifficultyRule,
killedBossIds: readonly string[],
): boolean {
return rule.finalBossIds?.length
? rule.finalBossIds.some((id) => killedBossIds.includes(id))
: rule.bossIds.every((id) => killedBossIds.includes(id));
}
+1 -1
View File
@@ -18,7 +18,7 @@ describe("dungeon pipeline runtime foundation", () => {
});
it("registers every reviewed instanced dungeon with WC first", () => {
expect(DUNGEON_DEFINITIONS).toHaveLength(84);
expect(DUNGEON_DEFINITIONS).toHaveLength(85);
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);
+35 -9
View File
@@ -21,6 +21,12 @@ import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCa
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
import { withWowDungeonEnvironment } from "./wowDungeonEnvironment";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentSourceId,
} from "./dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
@@ -177,7 +183,7 @@ function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefi
);
}
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
const sourceDungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
[authoredWailingCaverns.id, authoredWailingCaverns],
[FORSAKEN_ABBEY_DEFINITION.id, FORSAKEN_ABBEY_DEFINITION],
...RUNEWAKER_DUNGEON_DEFINITIONS.map((definition) => [definition.id, definition] as const),
@@ -186,13 +192,21 @@ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[]
.map((definition) => [definition.id, definition] as const),
];
const adaptedSourceEntries = sourceDungeonRegistryEntries.map(
([id, definition]) => [id, applyDungeonContentAdapters(definition)] as const,
);
const adaptedSourceRegistry = Object.fromEntries(adaptedSourceEntries) as Readonly<Record<string, DungeonDefinition>>;
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 dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
];
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
Object.fromEntries(dungeonRegistryEntries.map(
([id, definition]) => [
id,
applyDungeonContentAdapters(definition),
] as const,
)),
Object.fromEntries(dungeonRegistryEntries),
);
export type DungeonId = string;
@@ -201,6 +215,7 @@ export const DUNGEON_DEFINITIONS = Object.freeze(Object.values(DUNGEON_REGISTRY)
// Test rooms are session-only definitions; the shipped dungeon catalog stays immutable.
const bossTestingDefinitions = new Map<string, { definition: DungeonDefinition; sourceDungeonId: string }>();
const sessionDungeonDefinitions = new Map<string, DungeonDefinition>();
export function registerBossTestingDungeon(definition: DungeonDefinition, sourceDungeonId: string): void {
if (!definition.id.startsWith("boss-testing:") || !DUNGEON_REGISTRY[sourceDungeonId]) {
@@ -218,8 +233,19 @@ export function clearBossTestingDungeons(): void {
bossTestingDefinitions.clear();
}
/** 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);
sessionDungeonDefinitions.set(id, definition);
return definition;
}
export function dungeonDefinitionById(id: string): DungeonDefinition | null {
return bossTestingDefinitions.get(id)?.definition ?? DUNGEON_REGISTRY[id] ?? null;
return bossTestingDefinitions.get(id)?.definition
?? sessionDungeonDefinitions.get(id)
?? DUNGEON_REGISTRY[id]
?? null;
}
export function requireDungeonDefinition(id: string): DungeonDefinition {
@@ -239,7 +265,7 @@ export function dungeonIsEnabled(id: string): boolean {
const definition = dungeonDefinitionById(id);
if (!definition) return false;
const configured = (availability.enabled as Readonly<Record<string, boolean>>)[id];
const configured = (availability.enabled as Readonly<Record<string, boolean>>)[dungeonContentSourceId(id)];
if (definition.expansion !== "runewaker") return configured === true;
if (!dungeonPackagePassesOfflineGate(definition)) return false;
if (configured !== undefined) return configured;
+1 -1
View File
@@ -8,7 +8,7 @@ export const DUNGEON_RELEASE_STAGE_LABELS: Readonly<Record<DungeonReleaseStage,
};
export function dungeonReleaseStage(id: string): DungeonReleaseStage {
return id === "stormwind-stockade" ? "beta" : "alpha";
return id === "stormwind-stockade" || id === "bite-sized-stormwind-stockade" ? "beta" : "alpha";
}
export function compareDungeonReleaseOrder(
+4 -3
View File
@@ -30,7 +30,8 @@ export function completedDungeonRun(progress: DungeonRunProgress): DungeonRunRec
interface DungeonRunState {
readonly progress: DungeonRunProgress | null;
begin: (dungeonId: string, difficultyId: string, resume?: DungeonRunProgress, legacyResume?: boolean, bossDifficulty?: number) => void;
begin: (dungeonId: string, difficultyId: string, resume?: DungeonRunProgress, legacyResume?: boolean,
bossDifficulty?: number, runId?: string) => void;
start: (now: number, party: readonly DungeonRunMember[]) => void;
advance: (completedObjectiveIds: readonly string[], deadBossIds: readonly string[], now: number,
party: readonly DungeonRunMember[]) => void;
@@ -38,10 +39,10 @@ interface DungeonRunState {
export const useDungeonRunStore = create<DungeonRunState>((set, get) => ({
progress: null,
begin: (dungeonId, difficultyId, resume, legacyResume = false, bossDifficulty = 1) => set({
begin: (dungeonId, difficultyId, resume, legacyResume = false, bossDifficulty = 1, runId) => set({
progress: resume?.dungeonId === dungeonId && resume.difficultyId === difficultyId
&& resume.bossDifficulty === bossDifficulty ? resume : {
id: globalThis.crypto.randomUUID(), dungeonId, difficultyId, startedAt: null,
id: runId ?? globalThis.crypto.randomUUID(), dungeonId, difficultyId, startedAt: null,
finishedAt: null, durationMs: null, killedBossIds: [], party: [], timed: !legacyResume, bossDifficulty,
},
}),
+13 -3
View File
@@ -6,7 +6,13 @@ import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { resetPartyRuntimeClock } from "./partyRuntime";
import { usePartyStore } from "./partyStore";
import { useGameStore, type BossTestSession, type GameMode } from "./store";
import { bossTestingSourceDungeonId, dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
import {
bossTestingSourceDungeonId,
dungeonCanEnter,
dungeonDefinitionById,
prepareDungeonSessionDefinition,
type DungeonId,
} from "./dungeonRegistry";
import type { WorldSpawn } from "./dungeonTypes";
import type { GmReviewTarget } from "./gmContentReview";
import { shelvedDungeonSpawn } from "@shelvedDungeonSession";
@@ -39,6 +45,8 @@ export interface DungeonSessionOptions {
readonly encounterSnapshots?: readonly DungeonEncounterSnapshot[];
readonly runProgress?: DungeonRunProgress;
readonly legacyResume?: boolean;
/** Shared online activity id, or another stable fresh-run identity used by seeded variants. */
readonly runId?: string;
}
/**
@@ -51,7 +59,7 @@ export function activateDungeonSession(
difficultyId?: string,
options: DungeonSessionOptions = {},
): boolean {
const definition = dungeonDefinitionById(dungeonId);
let definition = dungeonDefinitionById(dungeonId);
const testingSource = bossTestingSourceDungeonId(dungeonId);
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
const dungeonSimulation = Boolean(options.dungeonSimulation && options.gmTestMode && dungeonRouteDefinition(dungeonId));
@@ -67,8 +75,10 @@ export function activateDungeonSession(
if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
if (!options.preserveCharacter) {
useDungeonRunStore.getState().begin(dungeonId, difficultyId ?? definition.defaultDifficultyId,
options.runProgress, options.legacyResume, bossDifficulty);
options.runProgress, options.legacyResume, bossDifficulty, options.runId);
}
const runSeed = useDungeonRunStore.getState().progress?.id ?? options.runId ?? "catalog-template";
definition = prepareDungeonSessionDefinition(dungeonId, runSeed) ?? definition;
activateDungeonEncounterDefinitions(
dungeonId,
definition.encounters ?? [],
+189
View File
@@ -0,0 +1,189 @@
import type { DungeonDefinition, DungeonBossObjective } from "../dungeonTypes";
import type { PopulationEntityDefinition, StaticMobSpawnDefinition } from "../mobPopulation";
import type { DungeonWorldObjectDefinition } from "../dungeonWorldObjects";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
} from "../dungeonContentSource";
const BOSS_ROOM_POSITION = [-159.582, -25.6062, 1.2531] as const;
const CENTRAL_CORRIDOR_HALF_WIDTH = 15;
export interface BiteSizedStockadesBossCandidate {
readonly sourceSpawnId: string;
readonly name: string;
}
export const BITE_SIZED_STOCKADES_BOSS_CANDIDATES: readonly BiteSizedStockadesBossCandidate[] = Object.freeze([
{ sourceSpawnId: "creature-84027", name: "Targorr the Dread" },
{ sourceSpawnId: "creature-79088", name: "Kam Deepfury" },
{ sourceSpawnId: "creature-79098", name: "Hamhock" },
{ sourceSpawnId: "creature-79124", name: "Bazil Thredd" },
{ sourceSpawnId: "creature-79116", name: "Dextren Ward" },
]);
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;
}
export function biteSizedStockadesBossIndex(seed: string): number {
return seedHash(seed) % BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length;
}
function branchWall(id: string, name: string, z: number): DungeonWorldObjectDefinition {
return {
id,
name,
kind: "gate",
position: [-129.17, -22.1, z],
halfExtents: [6, 5.2, 0.65],
initialState: "sealed",
states: [{
id: "sealed",
label: "Sealed",
visible: true,
blocksMovement: true,
blocksSight: true,
interactionEnabled: false,
}],
visual: { color: "#332f2b" },
source: {
fidelity: "healer-man-authored",
files: ["src/game/dungeons/biteSizedStockades.ts"],
note: "Permanent wall for the bite-sized route; the source Stockades geometry remains unchanged.",
},
};
}
function sourceBoss(
source: DungeonDefinition,
candidate: BiteSizedStockadesBossCandidate,
): { readonly spawn: StaticMobSpawnDefinition; readonly entity: PopulationEntityDefinition } {
const spawn = source.staticSpawns.find((entry) => entry.id === candidate.sourceSpawnId);
const entity = spawn ? source.entities[spawn.entityId] : undefined;
if (!spawn || !entity || entity.kind !== "boss") {
throw new Error(`Stockades boss source is missing: ${candidate.sourceSpawnId}`);
}
return { spawn, entity };
}
/**
* Builds a one-boss Stockades variant without mutating the generated source definition.
* The seed controls only boss identity, so saves and online party members can reconstruct
* the same run while a fresh run receives a new random selection.
*/
export function createBiteSizedStockadesDefinition(
source: DungeonDefinition,
seed: string,
): DungeonDefinition {
if (source.id !== STORMWIND_STOCKADE_DUNGEON_ID) {
throw new Error(`Bite-sized Stockades requires ${STORMWIND_STOCKADE_DUNGEON_ID}.`);
}
const candidate = BITE_SIZED_STOCKADES_BOSS_CANDIDATES[biteSizedStockadesBossIndex(seed)];
const selected = sourceBoss(source, candidate);
const sourceBossIds = new Set(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((entry) => entry.sourceSpawnId));
const corridorSpawns = source.staticSpawns.filter((spawn) => (
!sourceBossIds.has(spawn.id)
&& Math.abs(spawn.position[2]) <= CENTRAL_CORRIDOR_HALF_WIDTH
));
const usedEntityIds = new Set(corridorSpawns.map((spawn) => spawn.entityId));
const entities = Object.fromEntries([
...Object.entries(source.entities).filter(([entityId]) => usedEntityIds.has(entityId)),
[selected.spawn.entityId, selected.entity],
]);
const boss: DungeonBossObjective = {
id: candidate.sourceSpawnId,
name: candidate.name,
position: BOSS_ROOM_POSITION,
};
return {
...source,
id: BITE_SIZED_STOCKADES_DUNGEON_ID,
title: "Stormwind Stockade: Cellblock Rush",
lfgDungeonIds: [],
presentation: {
...source.presentation,
location: "Stormwind Stockade · Central Cellblock",
theme: "Bite-Sized Dungeon",
summary: "Race through the central cellblock and defeat one surprise warden chosen from the five Stockades bosses.",
loadingMessage: "Sealing the side wings and choosing a cellblock warden...",
unchartedAreaName: "Central Cellblock",
},
areas: [
{ ...source.entrance, id: "entrance", center: source.entrance.footPosition, radius: 35 },
{ id: "bite-sized-boss-room", name: `${candidate.name}'s Cell`, center: BOSS_ROOM_POSITION, radius: 32 },
],
entities,
staticSpawns: [
...corridorSpawns,
{
id: candidate.sourceSpawnId,
entityId: selected.spawn.entityId,
position: BOSS_ROOM_POSITION,
yaw: selected.spawn.yaw,
spawnMask: selected.spawn.spawnMask,
},
],
roamingPacks: [],
bosses: [boss],
objectiveOrder: [boss],
navigationLinks: source.navigationLinks.filter((link) => link.id === "entrance-to-central-stairs"),
traversalRamps: source.traversalRamps?.filter((ramp) => ramp.id === "entrance-stair-ramp"),
encounters: [{
id: `${BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`,
dungeonId: BITE_SIZED_STOCKADES_DUNGEON_ID,
initialPhase: "progression",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "progression", checkpointPhase: "progression" },
source: {
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedStockades.ts"],
reviewedBehavior: "A single seeded Stockades boss completes the bite-sized run in the first boss room.",
adaptations: [
"Each seeded run retains the selected source boss objective ID so saves, loot, guides, and leaderboards report the correct variant.",
"The two side-wing stairs are permanently sealed and omitted from party navigation.",
],
remainingGaps: [],
},
rules: [
{
id: "boss-defeated",
trigger: { kind: "entities-dead", entityIds: [candidate.sourceSpawnId] },
actions: [
{ id: "record-boss", kind: "complete-objective", objectiveId: candidate.sourceSpawnId, persistence: "run" },
{ id: "checkpoint-boss", kind: "set-checkpoint", checkpointId: candidate.sourceSpawnId, persistence: "checkpoint" },
],
},
{
id: "complete-run",
trigger: { kind: "tick" },
guards: [{ kind: "objective-complete", objectiveId: candidate.sourceSpawnId, equals: true }],
actions: [
{ id: "mark-dungeon-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "enter-complete-phase", kind: "set-phase", phase: "complete", persistence: "run" },
],
},
],
}],
encounterSpawnGroups: [],
serverGameObjects: [
...source.serverGameObjects.filter((object) => Math.abs(object.position[2]) <= CENTRAL_CORRIDOR_HALF_WIDTH),
branchWall("bite-sized-stockades-north-wall", "Sealed North Wing", 16.4),
branchWall("bite-sized-stockades-south-wall", "Sealed South Wing", -16.4),
],
validation: {
...source.validation,
warnings: [
...source.validation.warnings,
"Bite-sized layout and branch walls are Healer Man-authored; environment and combat assets are shared with Stormwind Stockade.",
],
},
};
}
@@ -216,6 +216,55 @@
],
"defaultDifficultyId": "normal"
},
{
"id": "bite-sized-stormwind-stockade",
"title": "Stormwind Stockade: Cellblock Rush",
"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": "black-morass",
"title": "The Black Morass",
+1 -1
View File
@@ -108,7 +108,7 @@ describe("looped mob path sampling", () => {
});
it("keeps every packaged dungeon patrol on a usable loop", () => {
expect(DUNGEON_DEFINITIONS).toHaveLength(84);
expect(DUNGEON_DEFINITIONS).toHaveLength(85);
for (const dungeon of DUNGEON_DEFINITIONS) {
for (const pack of dungeon.roamingPacks) {
expect(hasUsableLoopedMobPath(pack.waypoints), `${dungeon.id}:${pack.id}`).toBe(true);
+2 -2
View File
@@ -84,7 +84,7 @@ describe("party navigation fleet safety", () => {
const enabled = [];
const fallback = [];
expect(DUNGEON_DEFINITIONS).toHaveLength(84);
expect(DUNGEON_DEFINITIONS).toHaveLength(85);
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(30);
expect(enabled).toHaveLength(31);
expect(fallback).toHaveLength(54);
});
+4 -1
View File
@@ -4,9 +4,12 @@ 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";
describe("fleet-wide WoW dungeon environment policy", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
dungeon.expansion !== "runewaker" && dungeon.id !== BITE_SIZED_STOCKADES_DUNGEON_ID
));
it("gives every WoW dungeon readable ambient and hemisphere fill without changing fog draw distance or material roles", () => {
expect(wowDungeons).toHaveLength(54);
@@ -11,6 +11,7 @@ import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
import { useLoginInputStore } from "../app/loginInputStore";
export function connectAuthoritativeDualScreen(
channel: BroadcastChannel,
@@ -27,6 +28,9 @@ export function connectAuthoritativeDualScreen(
const unsubscribeShell = useShellStore.subscribe(() => {
if (companionReady) publish();
});
const unsubscribeLogin = useLoginInputStore.subscribe(() => {
if (companionReady) publish();
});
const unsubscribeGame = useGameStore.subscribe(() => {
if (companionReady) gamePublisher.request();
});
@@ -66,6 +70,7 @@ export function connectAuthoritativeDualScreen(
if (companionReady) publish();
return () => {
unsubscribeShell();
unsubscribeLogin();
unsubscribeGame();
unsubscribeCombat();
unsubscribeParty();
+25
View File
@@ -4,11 +4,13 @@ import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { emptyDpsMeter } from "../game/dpsMeter";
import type { DualScreenMessage } from "./dualScreenSync";
import { useLoginInputStore } from "../app/loginInputStore";
describe("dedicated companion DPS synchronization", () => {
afterEach(() => {
useUiLayoutStore.getState().finish(false);
useDpsMeterStore.setState(emptyDpsMeter(), true);
useLoginInputStore.getState().reset();
vi.unstubAllGlobals();
vi.useRealTimers();
});
@@ -45,4 +47,27 @@ describe("dedicated companion DPS synchronization", () => {
vi.advanceTimersByTime(100);
expect(postMessage).not.toHaveBeenCalled();
});
it("publishes login input immediately and stops after disconnect", () => {
vi.stubGlobal("window", { setTimeout, clearTimeout, location: { search: "?display=top" } });
const postMessage = vi.fn<(message: DualScreenMessage) => void>();
const channel = { postMessage, onmessage: null } as unknown as BroadcastChannel;
const disconnect = connectAuthoritativeDualScreen(
channel,
{ data: { type: "companion-ready" } } as MessageEvent<DualScreenMessage>,
);
postMessage.mockClear();
useLoginInputStore.getState().pressKey("char-1-0");
let latest = postMessage.mock.lastCall?.[0];
expect(latest?.type).toBe("app-state");
if (latest?.type !== "app-state") throw new Error("Missing login snapshot");
expect(latest.snapshot.login).toMatchObject({ username: "q", passwordLength: 0 });
disconnect();
postMessage.mockClear();
useLoginInputStore.getState().pressKey("char-1-1");
expect(postMessage).not.toHaveBeenCalled();
});
});
+17
View File
@@ -9,6 +9,7 @@ import { usePartyStore } from "../game/partyStore";
import { useGameStore } from "../game/store";
import { currentCompanionSnapshot, executeCompanionCommand } from "./dualScreenSync";
import { SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { useLoginInputStore } from "../app/loginInputStore";
describe("dual-screen party commands", () => {
beforeEach(() => {
@@ -24,6 +25,22 @@ describe("dual-screen party commands", () => {
command: "defend",
objectiveBossId: "lady-anacondra-spawn",
});
useLoginInputStore.getState().reset();
});
it("publishes safe login input and accepts semantic keyboard commands", () => {
executeCompanionCommand({ name: "pressLoginKey", keyId: "char-1-0" });
executeCompanionCommand({ name: "selectLoginField", field: "password" });
executeCompanionCommand({ name: "pressLoginKey", keyId: "char-1-1" });
expect(useLoginInputStore.getState()).toMatchObject({ username: "q", password: "w" });
expect(currentCompanionSnapshot().login).toMatchObject({
username: "q",
passwordLength: 1,
activeField: "password",
selectedKeyId: "char-1-1",
});
expect(JSON.stringify(currentCompanionSnapshot())).not.toContain('"password":"w"');
});
it("publishes the current command and named boss objective", () => {
+12
View File
@@ -36,6 +36,12 @@ import { currentHudSnapshot, type HudSnapshot } from "./hudSnapshot";
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
import { interactWithActiveDungeonObject } from "../game/dungeonWorldInteraction";
import { resurrectDeadPartyMembers } from "../game/partyResurrection";
import {
currentLoginInputSnapshot,
useLoginInputStore,
type LoginField,
type LoginInputSnapshot,
} from "../app/loginInputStore";
export { createDualScreenChannel } from "./dualScreenChannel";
@@ -142,6 +148,7 @@ export interface CompanionGameSnapshot {
export interface CompanionSnapshot {
keybinds?: InputPreferences;
phase: ShellPhase;
login: LoginInputSnapshot;
session: PlayerSession | null;
characters: CharacterProfile[];
selectedCharacterId: string | null;
@@ -152,6 +159,8 @@ export interface CompanionSnapshot {
}
export type CompanionCommand =
| { name: "pressLoginKey"; keyId: string }
| { name: "selectLoginField"; field: LoginField }
| { name: "selectCharacter"; characterId: string }
| { name: "openCreator" }
| { name: "cancelCreator" }
@@ -269,6 +278,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
return {
keybinds: getInputPreferences(),
phase: shell.phase,
login: currentLoginInputSnapshot(),
session: shell.session,
characters: shell.characters,
selectedCharacterId: shell.selectedCharacterId,
@@ -354,6 +364,8 @@ export function executeCompanionCommand(command: CompanionCommand): void {
const game = useGameStore.getState();
const combat = useCombatStore.getState();
switch (command.name) {
case "pressLoginKey": useLoginInputStore.getState().pressKey(command.keyId); break;
case "selectLoginField": useLoginInputStore.getState().setActiveField(command.field); break;
case "editHudElement": {
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft || !(command.id in HUD_ELEMENTS)) break;
const update: Partial<HudElementLayout> = {};
+4 -1
View File
@@ -3,6 +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 { prepareDungeonMaterial } from "./productionDungeonMaterials";
interface GltfMaterial {
@@ -26,7 +27,9 @@ function shippedMaterials(file: string): GltfMaterial[] {
}
}
const wowDungeons = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion !== "runewaker");
const wowDungeons = DUNGEON_DEFINITIONS.filter((definition) => (
definition.expansion !== "runewaker" && definition.id !== BITE_SIZED_STOCKADES_DUNGEON_ID
));
describe("shipped WoW dungeon material policies", () => {
it("covers all 54 campaign dungeons", () => {
+214 -42
View File
@@ -1965,45 +1965,135 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
}
/* Login */
.login-screen { display: grid; grid-template-columns: minmax(340px, 1fr) 390px; grid-template-rows: 58px 1fr 28px; column-gap: clamp(24px, 5vw, 72px); }
.login-screen > .front-brand { grid-column: 1; grid-row: 1; align-self: start; }
.login-hero { grid-column: 1; grid-row: 2; align-self: center; max-width: 500px; padding: 0 0 18px clamp(6px, 3vw, 36px); }
.login-rune { display: flex; width: 210px; align-items: center; gap: 12px; margin-bottom: 18px; color: var(--front-green-bright); }
.login-rune i { height: 1px; flex: 1; background: linear-gradient(90deg, transparent, rgba(195, 222, 145, 0.46)); }
.login-rune i:last-child { transform: scaleX(-1); }
.login-rune span { font-family: Georgia, serif; font-size: 24px; text-shadow: 0 0 18px rgba(180, 225, 119, 0.55); }
.login-hero h1 { margin: 8px 0 12px; font-family: Georgia, "Times New Roman", serif; font-size: clamp(35px, 5.5vw, 64px); font-weight: 400; line-height: 0.92; letter-spacing: -0.035em; }
.login-hero h1 em { color: var(--front-green-bright); font-weight: 400; }
.login-hero > p:not(.front-kicker) { max-width: 470px; margin: 0; color: var(--front-dim); font-size: 12px; line-height: 1.55; }
.login-pill-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 18px; }
.login-pill-row span { padding: 5px 8px; border: 1px solid rgba(193, 216, 164, 0.14); border-radius: 999px; color: #b7c5b3; font-size: 8px; text-transform: uppercase; letter-spacing: 0.08em; }
.login-form {
grid-column: 2;
grid-row: 1 / 3;
align-self: center;
padding: clamp(21px, 3vw, 31px);
border: 1px solid var(--front-line);
border-radius: 18px 5px 18px 5px;
background: linear-gradient(150deg, rgba(13, 30, 22, 0.95), rgba(6, 15, 11, 0.94));
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46);
.login-screen {
--login-ice: #9fd9df;
--login-gold: #d2b16d;
--login-gold-bright: #f1d99a;
display: grid;
grid-template-rows: minmax(98px, 1fr) auto 25px;
place-items: center;
background:
linear-gradient(180deg, rgba(2, 8, 16, .22), rgba(2, 7, 12, .12) 36%, rgba(1, 5, 9, .72) 100%),
radial-gradient(circle at 50% 42%, transparent 0 22%, rgba(0, 4, 8, .34) 72%),
url("./assets/ui/login/frozen-gateway.webp") center 43% / cover no-repeat,
#071019;
color: #f4eddd;
}
.login-form header { margin-bottom: 15px; }
.login-form header span { display: block; color: var(--front-gold); font-size: 8px; font-weight: 800; letter-spacing: 0.17em; text-transform: uppercase; }
.login-form header strong { display: block; margin: 4px 0; font-family: Georgia, serif; font-size: 24px; font-weight: 400; }
.login-form header small { color: var(--front-dim); font-size: 9px; }
.login-form > label, .appearance-controls > label { display: block; margin: 8px 0 4px; color: #bdc9ba; font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; }
.login-form > input, .appearance-controls > input {
.login-screen::before {
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(90deg, rgba(1,4,8,.48), transparent 24% 76%, rgba(1,4,8,.48));
content: "";
pointer-events: none;
}
.login-screen .front-grain { opacity: .1; mix-blend-mode: screen; }
.login-title {
position: relative;
z-index: 2;
align-self: start;
min-width: min(360px, 90vw);
padding-top: clamp(2px, 1.8vh, 15px);
text-align: center;
filter: drop-shadow(0 4px 10px rgba(0,0,0,.9));
}
.login-title__crest {
display: grid;
width: 44px;
height: 44px;
margin: 0 auto -8px;
place-items: center;
border: 1px solid rgba(217,190,124,.62);
background: radial-gradient(circle, #314956 0 37%, #15252e 38% 53%, #8d7748 55% 58%, #1a1714 60%);
box-shadow: 0 0 0 3px rgba(8,15,19,.78), 0 0 24px rgba(120,205,217,.34), inset 0 0 12px rgba(150,224,232,.3);
color: #c6edf0;
transform: rotate(45deg);
}
.login-title__crest i { font: normal 24px Georgia, serif; text-shadow: 0 0 8px #7ed7df; transform: rotate(-45deg); }
.login-title p { margin: 10px 0 0; color: var(--login-gold-bright); font-size: 8px; font-weight: 800; letter-spacing: .32em; text-transform: uppercase; }
.login-title h1 {
margin: 0;
color: #f6ebd0;
font: 600 clamp(35px, 5vw, 54px) Georgia, "Times New Roman", serif;
line-height: .95;
letter-spacing: -.055em;
text-shadow: 0 2px 0 #4b381d, 0 0 4px #000, 0 0 24px rgba(136,210,218,.25);
}
.login-title h1 em { color: var(--login-ice); font-weight: 400; }
.login-title small { color: #c8d4d2; font-family: Georgia, serif; font-size: 9px; letter-spacing: .12em; text-transform: uppercase; text-shadow: 0 2px 5px #000; }
.login-form {
position: relative;
z-index: 2;
grid-row: 2;
width: min(420px, calc(100vw - 28px));
align-self: end;
padding: 22px 24px 15px;
border: 1px solid rgba(176, 146, 84, .74);
border-radius: 3px 3px 13px 13px;
background:
linear-gradient(145deg, rgba(255,255,255,.045), transparent 30%),
linear-gradient(180deg, rgba(21,30,33,.97), rgba(7,13,17,.98));
box-shadow: 0 0 0 3px rgba(4,9,12,.86), 0 0 0 4px rgba(91,78,51,.7), 0 24px 60px rgba(0,0,0,.72), inset 0 0 38px rgba(85,122,128,.08);
backdrop-filter: blur(7px);
}
.login-form::before, .login-form::after {
position: absolute;
top: 7px;
width: 17px;
height: 17px;
border-color: rgba(222,190,121,.55);
content: "";
pointer-events: none;
}
.login-form::before { left: 7px; border-top: 1px solid; border-left: 1px solid; }
.login-form::after { right: 7px; border-top: 1px solid; border-right: 1px solid; }
.login-form__ornament { position: absolute; top: -10px; left: 50%; display: flex; width: 150px; align-items: center; gap: 7px; color: var(--login-gold-bright); transform: translateX(-50%); }
.login-form__ornament i { height: 1px; flex: 1; background: linear-gradient(90deg, transparent, var(--login-gold)); }
.login-form__ornament i:last-child { transform: scaleX(-1); }
.login-form__ornament span { display: grid; width: 20px; height: 20px; place-items: center; border: 1px solid rgba(215,183,113,.7); background: #101a1e; color: transparent; transform: rotate(45deg); }
.login-form__ornament span::after { color: var(--login-gold-bright); content: "+"; font: 15px Georgia, serif; transform: rotate(-45deg); }
.login-form header { margin-bottom: 11px; text-align: center; }
.login-form header span { display: block; color: var(--login-ice); font-size: 8px; font-weight: 800; letter-spacing: .22em; text-transform: uppercase; }
.login-form header strong { display: block; margin: 2px 0; color: #f4e5c4; font: 400 24px Georgia, serif; text-shadow: 0 1px 0 #000; }
.login-form header small { color: #9eacac; font-size: 9px; }
.login-form > label { display: block; margin: 7px 0 4px; color: #d1c4a4; font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: .1em; }
.login-form > input {
width: 100%;
height: 38px;
height: 40px;
padding: 0 11px;
border: 1px solid rgba(200, 219, 177, 0.18);
border-radius: 7px;
background: rgba(2, 8, 5, 0.66);
color: var(--front-cream);
border: 1px solid rgba(171,151,104,.48);
border-radius: 3px;
background: linear-gradient(180deg, rgba(1,5,8,.9), rgba(8,16,20,.82));
color: #f5ecda;
font: inherit;
font-size: 12px;
box-shadow: inset 0 2px 7px rgba(0,0,0,.65);
}
.login-form__actions { display: grid; gap: 7px; margin-top: 15px; }
.login-form > input::placeholder { color: #657476; }
.login-form > input.is-login-field-active { border-color: var(--login-gold-bright); box-shadow: inset 0 2px 7px rgba(0,0,0,.65), 0 0 0 2px rgba(225,190,110,.14), 0 0 15px rgba(130,211,219,.13); }
.login-form__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 13px; }
.login-form__actions .front-button {
min-height: 45px;
border-color: rgba(163,143,94,.38);
border-radius: 3px;
background: linear-gradient(180deg, rgba(50,61,61,.8), rgba(20,29,31,.86));
}
.login-form__actions .front-button:hover { border-color: rgba(224,192,119,.68); background: linear-gradient(180deg, rgba(61,74,73,.9), rgba(24,35,37,.92)); }
.login-form__actions .front-button--primary { grid-column: 1 / -1; justify-content: center; border-color: rgba(226,194,118,.76); background: linear-gradient(180deg, #806936, #473817); box-shadow: inset 0 1px 0 rgba(255,236,179,.22), 0 0 16px rgba(193,157,80,.08); text-align: center; }
.login-form__actions .front-button--primary:hover { background: linear-gradient(180deg, #967b40, #55421c); }
.login-form__actions .front-button--primary small { color: #d3c398; }
.login-form__actions .front-button--quiet { border-color: rgba(117,151,156,.24); background: linear-gradient(180deg, rgba(30,48,53,.72), rgba(13,25,29,.78)); }
.login-form footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; padding-top: 9px; border-top: 1px solid rgba(171,151,104,.18); color: #7f9294; font-size: 7px; letter-spacing: .06em; text-transform: uppercase; }
.login-form footer span { color: #a9c5c5; }
.login-form footer i { display: inline-block; width: 5px; height: 5px; margin-right: 4px; border-radius: 50%; background: #73b68b; box-shadow: 0 0 7px #73b68b; }
.login-lore { position: relative; z-index: 2; grid-row: 3; margin: 9px 0 0; color: rgba(225,224,211,.7); font: italic 9px Georgia, serif; letter-spacing: .08em; text-shadow: 0 2px 5px #000; }
.login-screen .controller-legend { color: #8e9b9d; text-shadow: 0 1px 4px #000; }
.login-screen .controller-legend b { color: var(--login-gold-bright); }
.login-screen :is(button, input):focus-visible, .login-screen .is-controller-selected { outline-color: var(--login-gold-bright) !important; box-shadow: 0 0 0 3px rgba(225,190,110,.2), 0 0 15px rgba(130,211,219,.2) !important; }
.single-display-frame:has(.login-screen) .single-context-toggle { border-color: rgba(176,146,84,.58); border-radius: 3px; background: rgba(11,20,24,.94); box-shadow: 0 0 0 2px rgba(4,9,12,.72), 0 12px 35px rgba(0,0,0,.55); }
.single-display-frame:has(.login-screen) .single-context-toggle small { color: #f1d99a; }
.appearance-controls > label { display: block; margin: 8px 0 4px; color: #bdc9ba; font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
.appearance-controls > input { width: 100%; height: 38px; padding: 0 11px; border: 1px solid rgba(200,219,177,.18); border-radius: 7px; background: rgba(2,8,5,.66); color: var(--front-cream); font: inherit; font-size: 12px; }
/* Character silhouette preview */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@@ -2184,7 +2274,7 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.dungeon-select-list > header { position: relative; }
.dungeon-select-list > header h1 { margin: 3px 0 10px; font: 28px Georgia, serif; font-weight: 400; }
.dungeon-select-list > header > small { position: absolute; right: 0; bottom: 14px; color: var(--front-dim); font-size: 7px; letter-spacing: .1em; text-transform: uppercase; }
.dungeon-category-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 0 3px 10px; }
.dungeon-category-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; padding: 0 3px 10px; }
.dungeon-category-tab { display: flex; min-height: 39px; align-items: center; justify-content: space-between; gap: 6px; padding: 7px 9px; border: 1px solid rgba(204,224,180,.13); border-radius: 7px; background: rgba(255,255,255,.025); color: var(--front-cream); cursor: pointer; text-align: left; }
.dungeon-category-tab strong { color: var(--front-dim); font: 14px Georgia, serif; font-weight: 400; }
.dungeon-category-tab small { color: var(--front-dim); font-size: 6px; letter-spacing: .04em; text-align: right; text-transform: uppercase; }
@@ -2446,17 +2536,74 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.companion-panel > header > b { color: var(--front-green-bright); font-size: 7px; letter-spacing: .15em; }
.companion-panel > main { min-height: 0; overflow: hidden; }
.companion-panel > footer { display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--front-line); color: var(--front-dim); font-size: 7px; letter-spacing: .08em; }
.companion-login {
background:
linear-gradient(180deg, rgba(3,8,13,.78), rgba(2,7,11,.94)),
url("./assets/ui/login/frozen-gateway.webp") center / cover no-repeat,
#071019;
}
.companion-login .front-grain { opacity: .08; }
.companion-login .front-brand .brand-sigil { border-color: rgba(217,190,124,.5); color: #a9e0e4; box-shadow: inset 0 0 16px rgba(114,199,207,.18); }
.companion-login :is(.front-brand small, .companion-kicker) { color: #f1d99a; }
.companion-login > header > b { color: #9fd9df; }
.companion-login > main { display: grid; align-content: center; padding: 12px 24px; }
.companion-login h2 { max-width: 390px; margin: 6px 0 15px; font: 28px Georgia, serif; }
.companion-feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.companion-feature-grid article { display: flex; min-height: 100px; flex-direction: column; justify-content: space-between; padding: 11px; border: 1px solid var(--front-line); border-radius: 9px; background: rgba(255,255,255,.025); }
.companion-feature-grid article > b { color: var(--front-gold); font: 18px Georgia, serif; }
.companion-login .companion-feature-grid article { border-color: rgba(176,146,84,.3); border-radius: 3px; background: linear-gradient(180deg, rgba(31,42,44,.86), rgba(10,18,21,.9)); box-shadow: inset 0 1px 0 rgba(255,255,255,.035); }
.companion-login .companion-feature-grid article > b { color: #d2b16d; }
.companion-feature-grid article span { display: flex; flex-direction: column; }
.companion-feature-grid article strong { font-size: 9px; }
.companion-feature-grid article small { margin-top: 3px; color: var(--front-dim); font-size: 7px; line-height: 1.35; }
.companion-login-keyboard { grid-template-rows: 44px minmax(0, 1fr) 31px; }
.companion-login-keyboard > main { display: grid; grid-template-rows: 48px minmax(0, 1fr); align-content: stretch; gap: 6px; padding: 7px 0; }
.login-keyboard-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.login-keyboard-fields button {
display: flex;
min-width: 0;
flex-direction: column;
justify-content: center;
padding: 5px 9px;
overflow: hidden;
border: 1px solid rgba(159,217,223,.2);
border-radius: 3px;
background: rgba(5,13,17,.86);
color: #899a9c;
cursor: pointer;
text-align: left;
}
.login-keyboard-fields button.is-active { border-color: rgba(241,217,154,.78); color: #f4eddd; background: linear-gradient(180deg, rgba(45,55,55,.9), rgba(17,27,30,.92)); box-shadow: inset 0 0 14px rgba(128,200,207,.08); }
.login-keyboard-fields small, .login-keyboard-fields strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.login-keyboard-fields small { color: #9fd9df; font-size: 7px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.login-keyboard-fields strong { margin-top: 2px; font: 13px Georgia, serif; }
.login-keyboard-grid { display: grid; min-height: 0; grid-template-columns: repeat(10, minmax(0, 1fr)); grid-template-rows: repeat(6, minmax(0, 1fr)); gap: 4px; }
.login-keyboard-key {
min-width: 0;
min-height: 0;
padding: 2px;
border: 1px solid rgba(160,182,180,.25);
border-radius: 3px;
background: linear-gradient(180deg, rgba(43,54,56,.94), rgba(17,26,29,.96));
box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 2px 3px rgba(0,0,0,.32);
color: #f1ead8;
cursor: pointer;
font-size: 11px;
font-weight: 800;
text-align: center;
}
.login-keyboard-key:hover { border-color: rgba(241,217,154,.58); background: linear-gradient(180deg, rgba(58,70,71,.96), rgba(23,34,37,.98)); }
.login-keyboard-key.is-controller-selected { z-index: 2; border-color: #f1d99a; outline: 2px solid #f1d99a !important; outline-offset: -2px; background: linear-gradient(180deg, #806936, #473817); box-shadow: inset 0 1px 0 rgba(255,236,179,.24), 0 0 13px rgba(149,216,222,.32) !important; }
.login-keyboard-key.is-unavailable { color: #657174; background: rgba(10,17,20,.82); }
.login-keyboard-key--mode, .login-keyboard-key--field, .login-keyboard-key--space,
.login-keyboard-key--backspace, .login-keyboard-key--clear, .login-keyboard-key--done { color: #c7dadd; background: linear-gradient(180deg, rgba(35,57,62,.94), rgba(13,29,33,.96)); }
.login-keyboard-key--login { border-color: rgba(226,194,118,.58); color: #fff0c5; background: linear-gradient(180deg, #755f30, #403213); }
.login-keyboard-key--create, .login-keyboard-key--offline { border-color: rgba(176,146,84,.38); color: #eadbb8; }
.companion-login-keyboard > footer strong { overflow: hidden; color: #9fd9df; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.companion-login-keyboard > footer b { color: #f1d99a; }
.companion-roster > main { display: grid; grid-template-columns: 190px 1fr; gap: 11px; padding: 9px 0; }
.companion-dungeon-browser { display: grid; min-height: 0; grid-template-rows: auto minmax(0, 1fr); gap: 6px; }
.companion-category-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
.companion-category-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; }
.companion-category-tabs button { display: flex; min-height: 30px; align-items: center; justify-content: space-between; gap: 5px; padding: 5px 7px; border: 1px solid rgba(204,224,180,.13); border-radius: 6px; background: rgba(255,255,255,.025); color: var(--front-cream); cursor: pointer; }
.companion-category-tabs button.is-active { border-color: rgba(218,192,102,.52); background: rgba(111,126,66,.22); }
.companion-category-tabs strong { color: var(--front-gold); font: 11px Georgia, serif; font-weight: 400; }
@@ -2589,10 +2736,17 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
@media (max-width: 760px) {
.front-surface--top { padding: 11px; }
.login-screen { display: block; overflow: auto; }
.login-screen > .front-brand { position: absolute; top: 12px; left: 13px; }
.login-hero { display: none; }
.login-form { width: min(390px, calc(100% - 16px)); margin: 48px auto 28px; padding: 18px; }
.login-screen { display: grid; grid-template-rows: minmax(94px, 1fr) auto 62px; overflow: auto; background-position: center 42%; }
.login-title { min-width: min(330px, 88vw); padding-top: max(3px, env(safe-area-inset-top)); }
.login-title__crest { width: 38px; height: 38px; }
.login-title h1 { font-size: clamp(34px, 11vw, 46px); }
.login-title small { font-size: 8px; }
.login-form { width: min(410px, calc(100vw - 24px)); padding: 20px 17px 13px; }
.login-form > input { height: 44px; font-size: 16px; }
.login-form__actions .front-button { min-height: 48px; }
.login-form__actions .front-button span { font-size: 11px; }
.login-form__actions .front-button small { font-size: 7px; }
.login-form footer, .login-lore { display: none; }
.controller-legend { display: none; }
.character-select-screen { display: grid; grid-template-columns: 43% 57%; grid-template-rows: 48px minmax(0,1fr); }
.roster-header { grid-column: 1 / 3; }
@@ -2692,11 +2846,29 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
}
@media (max-height: 500px) {
.login-form { padding: 16px 20px; }
.login-form header { margin-bottom: 8px; }
.login-form__actions { grid-template-columns: 1fr 1fr 1fr; }
.login-screen { grid-template-columns: minmax(190px, .8fr) minmax(380px, 1.2fr); grid-template-rows: 1fr; gap: 20px; overflow: auto; background-position: center 40%; }
.login-title { grid-column: 1; grid-row: 1; align-self: center; min-width: 0; padding-left: 10px; }
.login-title h1 { font-size: clamp(32px, 5vw, 42px); }
.login-form { grid-column: 2; grid-row: 1; align-self: center; width: min(560px, 100%); padding: 16px 20px 11px; }
.login-form header { margin-bottom: 7px; }
.login-form header strong { font-size: 20px; }
.login-form > label { margin-top: 5px; }
.login-form > input { height: 36px; }
.login-form__actions { grid-template-columns: 1.1fr 1fr 1fr; margin-top: 9px; }
.login-form__actions .front-button--primary { grid-column: auto; }
.login-form__actions .front-button { min-width: 0; }
.login-form__actions small { display: none; }
.login-form footer, .login-lore { display: none; }
}
@media (max-width: 620px) and (max-height: 500px) {
.login-screen { display: grid; grid-template-columns: 1fr; grid-template-rows: 54px auto; gap: 4px; align-content: center; background-position: center; }
.login-title { grid-column: 1; grid-row: 1; align-self: start; display: grid; grid-template-columns: 36px auto; width: min(420px, calc(100vw - 24px)); align-items: center; justify-content: center; gap: 0 12px; padding: 0; }
.login-title__crest { grid-row: 1 / 3; width: 32px; height: 32px; margin: 0; }
.login-title p { align-self: end; margin: 0; font-size: 6px; text-align: left; }
.login-title h1 { align-self: start; font-size: 27px; text-align: left; }
.login-title small { display: none; }
.login-form { grid-column: 1; grid-row: 2; width: min(480px, calc(100vw - 24px)); }
}
.world-transition {
+16 -11
View File
@@ -1,38 +1,43 @@
import { useMemo, useState } from "react";
import { buildBossGuideCatalog } from "../game/bossGuide";
import { BOSS_GUIDE_OVERRIDES } from "../game/bossGuideOverrides";
import { DUNGEON_DEFINITIONS } from "../game/dungeonRegistry";
import { DUNGEON_DEFINITIONS, dungeonDefinitionById } from "../game/dungeonRegistry";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
const GUIDE_CATALOG = buildBossGuideCatalog(DUNGEON_DEFINITIONS, BOSS_GUIDE_OVERRIDES);
function BossGuideDialog() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const sessionRevision = useGameStore((state) => state.sessionRevision);
const bossTest = useGameStore((state) => state.bossTest);
const openOverlay = useGameStore((state) => state.openOverlay);
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? GUIDE_CATALOG[0];
const guideCatalog = useMemo(() => buildBossGuideCatalog(
DUNGEON_DEFINITIONS.map((definition) => (
definition.id === activeDungeonId ? dungeonDefinitionById(activeDungeonId) ?? definition : definition
)),
BOSS_GUIDE_OVERRIDES,
), [activeDungeonId, sessionRevision]);
const initialDungeon = guideCatalog.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? guideCatalog[0];
const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
const selectedDungeon = guideCatalog.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses.find((boss) => boss.entityId === bossTest?.entityId)?.id ?? selectedDungeon?.bosses[0]?.id ?? "");
const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
?? selectedDungeon?.bosses[0];
const close = () => openOverlay("pause");
const selectDungeon = (dungeonId: string) => {
const dungeon = GUIDE_CATALOG.find((candidate) => candidate.id === dungeonId);
const dungeon = guideCatalog.find((candidate) => candidate.id === dungeonId);
setSelectedDungeonId(dungeonId);
setSelectedBossId(dungeon?.bosses[0]?.id ?? "");
};
const actions = useMemo<MenuAction[]>(() => {
const dungeonActions = GUIDE_CATALOG.map((dungeon, index): MenuAction => ({
const dungeonActions = guideCatalog.map((dungeon, index): MenuAction => ({
id: `boss-guide-dungeon:${dungeon.id}`,
run: () => selectDungeon(dungeon.id),
neighbors: {
up: `boss-guide-dungeon:${GUIDE_CATALOG[(index - 1 + GUIDE_CATALOG.length) % GUIDE_CATALOG.length].id}`,
down: `boss-guide-dungeon:${GUIDE_CATALOG[(index + 1) % GUIDE_CATALOG.length].id}`,
up: `boss-guide-dungeon:${guideCatalog[(index - 1 + guideCatalog.length) % guideCatalog.length].id}`,
down: `boss-guide-dungeon:${guideCatalog[(index + 1) % guideCatalog.length].id}`,
...(dungeon.id === selectedDungeon?.id && dungeon.bosses.length
? { right: `boss-guide-boss:${dungeon.bosses[0].id}` }
: {}),
@@ -49,7 +54,7 @@ function BossGuideDialog() {
},
}));
return [...dungeonActions, ...bossActions];
}, [selectedDungeon]);
}, [guideCatalog, selectedDungeon]);
const controller = useMenuController(actions, {
initialId: `boss-guide-dungeon:${selectedDungeon?.id ?? ""}`,
onBack: close,
@@ -74,7 +79,7 @@ function BossGuideDialog() {
<nav className="boss-guide-panel__dungeons" aria-label="Dungeons">
<h3>Dungeons</h3>
<div>
{GUIDE_CATALOG.map((dungeon) => {
{guideCatalog.map((dungeon) => {
const controlId = `boss-guide-dungeon:${dungeon.id}`;
return (
<ControllerButton
+4 -15
View File
@@ -36,6 +36,8 @@ import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { currentDisplayLayout } from "../platform/displayLayout";
import { DungeonCompanionHud } from "./DungeonCompanionHud";
import { LoginVirtualKeyboard } from "./LoginVirtualKeyboard";
import { useLoginInputStore } from "../app/loginInputStore";
function safePercent(current: number, maximum: number): number {
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
@@ -161,21 +163,7 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
const active = snapshot.activeCharacter ?? selected;
if (snapshot.phase === "login") {
return (
<FrontSurface bottom className="companion-panel companion-login" ariaLabel="Healer Man companion login information">
<header><BrandMark compact /><b>SECONDARY DISPLAY</b></header>
<main>
<p className="companion-kicker">Ready on both screens</p>
<h2>One roster. Two ways to play.</h2>
<div className="companion-feature-grid">
<article><b>01</b><span><strong>Your roster</strong><small>Choose your adventurer after signing in</small></span></article>
<article><b>02</b><span><strong>Offline play</strong><small>No sign-in or network required</small></span></article>
<article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article>
</div>
</main>
<footer><span>Sign in on the top screen</span><b>THOR / 3.92 INCHES</b></footer>
</FrontSurface>
);
return <LoginVirtualKeyboard snapshot={snapshot.login} notice={snapshot.notice} send={send} />;
}
if (snapshot.phase === "characters") {
@@ -526,6 +514,7 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
export function LocalCompanionPanel() {
const shell = useShellStore();
useLoginInputStore();
useGameStore();
useCombatStore();
usePartyStore();
+5 -2
View File
@@ -1,6 +1,7 @@
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import type { DungeonRunMember } from "../game/dungeonRunTypes";
import { bossDifficultyLabel } from "../game/dungeonDifficulty";
import { leaderboardFinalBossIds } from "../game/dungeonLeaderboardRules";
const className = (id: string) => catalog.classes.find((entry) => entry.id === id)?.name ?? id;
@@ -12,7 +13,9 @@ export function DungeonRunDetails({ dungeonId, difficultyId, bossDifficulty, par
readonly killedBossIds: readonly string[];
}) {
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
const ids = dungeon?.difficulties.find((entry) => entry.id === difficultyId)?.bossIds ?? [];
const difficulty = dungeon?.difficulties.find((entry) => entry.id === difficultyId);
const variant = difficulty && leaderboardFinalBossIds(difficulty).length > 1;
const ids = variant ? killedBossIds : difficulty?.bossIds ?? [];
const bosses = dungeon?.bosses.filter((boss) => ids.includes(boss.id)) ?? [];
return <div className="dungeon-run-details">
<p>{bossDifficultyLabel(bossDifficulty)} · {difficultyId === "heroic" ? "Heroic" : "Normal"}</p>
@@ -24,7 +27,7 @@ export function DungeonRunDetails({ dungeonId, difficultyId, bossDifficulty, par
<small>{member.role === "damage" ? "Damage" : member.role === "healer" ? "Healer" : "Tank"}</small>
</li>)}
</ul>
<h4>Bosses defeated · {killedBossIds.length}/{bosses.length}</h4>
<h4>Bosses defeated · {Math.min(killedBossIds.length, bosses.length)}/{bosses.length}</h4>
<ul className="dungeon-run-bosses">
{bosses.map((boss) => <li key={boss.id} className={killedBossIds.includes(boss.id) ? "is-defeated" : ""}>
<span aria-hidden="true">{killedBossIds.includes(boss.id) ? "✓" : "—"}</span>
+3 -2
View File
@@ -10,6 +10,7 @@ import { useMenuController } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { DungeonRunDetails } from "./DungeonRunDetails";
import "./dungeonRuns.css";
import { isLeaderboardFullClear } from "../game/dungeonLeaderboardRules";
export interface RunPlacementState {
readonly scope: LeaderboardScope;
@@ -25,8 +26,8 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
readonly onRetry: () => void;
}) {
const definition = catalog.dungeons.find((dungeon) => dungeon.id === progress.dungeonId);
const fullClear = definition?.difficulties.find((entry) => entry.id === progress.difficultyId)?.bossIds
.every((id) => progress.killedBossIds.includes(id)) ?? false;
const difficulty = definition?.difficulties.find((entry) => entry.id === progress.difficultyId);
const fullClear = difficulty ? isLeaderboardFullClear(difficulty, progress.killedBossIds) : false;
const controller = useMenuController([
{ id: "run-loot", run: onLoot }, { id: "run-finish", run: onFinish },
...(ranking.scope === "device" ? [{ id: "run-retry", run: onRetry }] : []),
+2 -1
View File
@@ -36,6 +36,7 @@ import { bossDifficultyLabel } from "../game/dungeonDifficulty";
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { dungeonContentPackId } from "../game/dungeonContentSource";
installDungeonShellRuntime();
@@ -172,7 +173,7 @@ export function DungeonSelectScreen() {
setDownloading(true);
setDownloadError("");
try {
await installContentPack(`dungeon:${pendingDownload.dungeonId}`);
await installContentPack(dungeonContentPackId(pendingDownload.dungeonId));
if (!dungeonContentIsUsable(pendingDownload.dungeonId)) {
throw new Error("The downloaded expedition could not be verified on this device.");
}
+73 -42
View File
@@ -1,7 +1,8 @@
import { useMemo, useRef, useState } from "react";
import { useCallback, useEffect } from "react";
import {
createOfflineSession,
} from "../app/accountRepository";
import { useLoginInputStore, type LoginKeyboardDirection } from "../app/loginInputStore";
import {
createOnlineAccount,
hydrateOnlineSession,
@@ -9,22 +10,34 @@ import {
} from "../app/onlineAccountClient";
import { useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
import { INPUT_ACTIONS, controllerBindingMatches } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { ControllerButton } from "./ControllerButton";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { ControllerLegend, FrontSurface } from "./FrontSurface";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
export function LoginScreen() {
const beginSession = useShellStore((state) => state.beginSession);
const notice = useShellStore((state) => state.notice);
const setNotice = useShellStore((state) => state.setNotice);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState<"login" | "create" | null>(null);
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const username = useLoginInputStore((state) => state.username);
const password = useLoginInputStore((state) => state.password);
const activeField = useLoginInputStore((state) => state.activeField);
const selectedKeyId = useLoginInputStore((state) => state.selectedKeyId);
const busy = useLoginInputStore((state) => state.busy);
const request = useLoginInputStore((state) => state.request);
const setUsername = useLoginInputStore((state) => state.setUsername);
const setPassword = useLoginInputStore((state) => state.setPassword);
const setActiveField = useLoginInputStore((state) => state.setActiveField);
const selectKey = useLoginInputStore((state) => state.selectKey);
const moveSelection = useLoginInputStore((state) => state.moveSelection);
const pressSelected = useLoginInputStore((state) => state.pressSelected);
const consumeRequest = useLoginInputStore((state) => state.consumeRequest);
const setBusy = useLoginInputStore((state) => state.setBusy);
const resetLoginInput = useLoginInputStore((state) => state.reset);
const logIn = async () => {
const logIn = useCallback(async () => {
if (busy) return;
setBusy("login");
setNotice("Contacting the online server...");
@@ -33,15 +46,16 @@ export function LoginScreen() {
try {
setNotice("Synchronizing your cloud roster...");
await hydrateOnlineSession(result.session);
resetLoginInput();
beginSession(result.session);
} catch (error) {
setNotice(error instanceof Error ? error.message : "Cloud roster could not be loaded.");
}
} else setNotice(result.error ?? "Login failed.");
setBusy(null);
};
}, [beginSession, busy, password, resetLoginInput, setBusy, setNotice, username]);
const createAccount = async () => {
const createAccount = useCallback(async () => {
if (busy) return;
setBusy("create");
setNotice("Creating your online account...");
@@ -49,47 +63,63 @@ export function LoginScreen() {
if (result.ok && result.session) {
try {
await hydrateOnlineSession(result.session);
resetLoginInput();
beginSession(result.session);
} catch (error) {
setNotice(error instanceof Error ? error.message : "Cloud roster could not be initialized.");
}
} else setNotice(result.error ?? "Account could not be created.");
setBusy(null);
};
}, [beginSession, busy, password, resetLoginInput, setBusy, setNotice, username]);
const playOffline = () => beginSession(createOfflineSession());
const actions = useMemo<MenuAction[]>(() => [
{ id: "username", run: () => usernameRef.current?.focus() },
{ id: "password", run: () => passwordRef.current?.focus() },
{ id: "login", run: () => { void logIn(); }, enabled: !busy },
{ id: "create-account", run: () => { void createAccount(); }, enabled: !busy },
{ id: "offline", run: playOffline, enabled: !busy },
], [busy, password, username]);
const controller = useMenuController(actions);
const playOffline = useCallback(() => {
resetLoginInput();
beginSession(createOfflineSession());
}, [beginSession, resetLoginInput]);
useEffect(() => {
if (!request || busy) return;
consumeRequest(request.id);
if (request.action === "login") void logIn();
else if (request.action === "create") void createAccount();
else playOffline();
}, [busy, consumeRequest, createAccount, logIn, playOffline, request]);
useEffect(() => subscribeControllerToken(({ token, repeat, pressed }) => {
if (pressed === false || isCapturingInput()) return;
const bindings = getInputPreferences().controller;
const command = INPUT_ACTIONS
.filter((entry) => entry.context === "menu" && controllerBindingMatches(bindings[entry.id], token, getControllerHeldTokens()))
.sort((left, right) => (bindings[right.id]?.length ?? 0) - (bindings[left.id]?.length ?? 0))[0]
?.id.replace(/-alt$/, "");
const direction = ["menu-up", "menu-down", "menu-left", "menu-right"].includes(command ?? "")
? command!.slice(5) as LoginKeyboardDirection
: null;
if (direction) moveSelection(direction);
else if (!repeat && command === "menu-confirm") pressSelected();
}), [moveSelection, pressSelected]);
return (
<DualDisplayFrame
contextLabel="Roster info"
contextLabel="Controller keyboard"
top={
<FrontSurface className="login-screen" ariaLabel="Healer Man login">
<BrandMark />
<div className="login-hero">
<div className="login-rune" aria-hidden="true"><i /><span>+</span><i /></div>
<p className="front-kicker">Your next expedition awaits</p>
<h1>The party needs<br /><em>a healer.</em></h1>
<p>Sign in to your cloud roster, create an online account, or head in completely offline.</p>
<div className="login-pill-row"><span>Controller ready</span><span>Thor dual-screen</span><span>PC compatible</span></div>
</div>
<header className="login-title">
<span className="login-title__crest" aria-hidden="true"><i>+</i></span>
<p>Answer the call</p>
<h1>Healer <em>Man</em></h1>
<small>Your next expedition awaits</small>
</header>
<form className="login-form" onSubmit={(event) => { event.preventDefault(); void logIn(); }}>
<header><span>Account gate</span><strong>Begin your expedition</strong><small>Online accounts synchronize characters through iwanttoheal.phenomrom.com.</small></header>
<div className="login-form__ornament" aria-hidden="true"><i /><span>+</span><i /></div>
<header><span>Account gateway</span><strong>Enter the realm</strong><small>Continue with your cloud roster or answer the call offline.</small></header>
<label htmlFor="login-username">Username</label>
<input
ref={usernameRef}
id="login-username"
className={controller.isSelected("username") ? "is-controller-selected" : ""}
className={activeField === "username" ? "is-login-field-active" : ""}
value={username}
onChange={(event) => setUsername(event.target.value)}
onFocus={() => controller.select("username")}
onFocus={() => setActiveField("username")}
autoComplete="username"
inputMode="text"
maxLength={20}
@@ -97,30 +127,31 @@ export function LoginScreen() {
/>
<label htmlFor="login-password">Password</label>
<input
ref={passwordRef}
id="login-password"
className={controller.isSelected("password") ? "is-controller-selected" : ""}
className={activeField === "password" ? "is-login-field-active" : ""}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onFocus={() => controller.select("password")}
onFocus={() => setActiveField("password")}
autoComplete="current-password"
maxLength={128}
placeholder="8 characters minimum"
/>
<div className="login-form__actions">
<ControllerButton controlId="login" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="submit" disabled={Boolean(busy)}>
<span>{busy === "login" ? "Checking..." : "Login"}</span><small>Load your cloud roster</small>
<ControllerButton controlId="action-login" selectedId={selectedKeyId} select={selectKey} className="front-button front-button--primary" type="submit" disabled={Boolean(busy)}>
<span>{busy === "login" ? "Opening gateway..." : "Enter world"}</span><small>Load your cloud roster</small>
</ControllerButton>
<ControllerButton controlId="create-account" selectedId={controller.selectedId} select={controller.select} className="front-button" type="button" onClick={() => { void createAccount(); }} disabled={Boolean(busy)}>
<span>{busy === "create" ? "Creating..." : "Create account"}</span><small>Start a synchronized roster</small>
<ControllerButton controlId="action-create" selectedId={selectedKeyId} select={selectKey} className="front-button" type="button" onClick={() => { void createAccount(); }} disabled={Boolean(busy)}>
<span>{busy === "create" ? "Forging account..." : "Create account"}</span><small>Start a synchronized roster</small>
</ControllerButton>
<ControllerButton controlId="offline" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--quiet" type="button" onClick={playOffline} disabled={Boolean(busy)}>
<ControllerButton controlId="action-offline" selectedId={selectedKeyId} select={selectKey} className="front-button front-button--quiet" type="button" onClick={playOffline} disabled={Boolean(busy)}>
<span>Play offline</span><small>Skip account login</small>
</ControllerButton>
</div>
{notice && <p className="front-notice" role="status" aria-live="polite">{notice}</p>}
<footer><span><i /> Online roster available</span><small>Secure account gateway</small></footer>
</form>
<p className="login-lore">Gather your party. Keep them standing.</p>
<ControllerLegend />
</FrontSurface>
}
+80
View File
@@ -0,0 +1,80 @@
import type { CSSProperties } from "react";
import {
loginKeyboardRows,
type LoginField,
type LoginInputSnapshot,
type LoginKeyDefinition,
} from "../app/loginInputStore";
import type { CompanionCommand } from "../platform/dualScreenSync";
import { BrandMark, FrontSurface } from "./FrontSurface";
function passwordPreview(length: number): string {
if (!length) return "Password";
return `${"•".repeat(Math.min(length, 18))}${length > 18 ? "…" : ""}`;
}
function keyLabel(key: LoginKeyDefinition, activeField: LoginField): string {
if (key.kind === "field") return activeField === "username" ? "Password" : "Username";
return key.label;
}
export function LoginVirtualKeyboard({ snapshot, notice, send }: {
snapshot: LoginInputSnapshot;
notice: string;
send: (command: CompanionCommand) => void;
}) {
const rows = loginKeyboardRows(snapshot.keyboardMode);
const chooseField = (field: LoginField) => send({ name: "selectLoginField", field });
return (
<FrontSurface bottom className="companion-panel companion-login companion-login-keyboard" ariaLabel="Controller login keyboard">
<header><BrandMark compact /><b>CONTROLLER KEYBOARD</b></header>
<main>
<div className="login-keyboard-fields" role="group" aria-label="Login fields">
<button
type="button"
className={snapshot.activeField === "username" ? "is-active" : ""}
aria-pressed={snapshot.activeField === "username"}
onClick={() => chooseField("username")}
>
<small>Username</small><strong>{snapshot.username || "Select to enter"}</strong>
</button>
<button
type="button"
className={snapshot.activeField === "password" ? "is-active" : ""}
aria-pressed={snapshot.activeField === "password"}
onClick={() => chooseField("password")}
>
<small>Password</small><strong>{passwordPreview(snapshot.passwordLength)}</strong>
</button>
</div>
<div className="login-keyboard-grid" role="group" aria-label={`${snapshot.keyboardMode} virtual keyboard`}>
{rows.flat().map((key) => {
const isAction = ["login", "create", "offline"].includes(key.kind);
const unavailableForUsername = snapshot.activeField === "username"
&& key.kind === "character"
&& !/^[A-Za-z0-9_]$/.test(key.value ?? "");
return (
<button
key={key.id}
type="button"
className={`login-keyboard-key login-keyboard-key--${key.kind} ${snapshot.selectedKeyId === key.id ? "is-controller-selected" : ""} ${unavailableForUsername ? "is-unavailable" : ""}`}
style={{ gridColumn: `span ${key.span ?? 1}` } as CSSProperties}
aria-label={key.ariaLabel}
aria-current={snapshot.selectedKeyId === key.id ? "true" : undefined}
disabled={isAction && snapshot.busy !== null}
onClick={() => send({ name: "pressLoginKey", keyId: key.id })}
>
{keyLabel(key, snapshot.activeField)}
</button>
);
})}
</div>
</main>
<footer>
<span><b>D-Pad</b> Move &nbsp; <b>A</b> Type</span>
<strong>{snapshot.busy ? `${snapshot.busy === "create" ? "Creating account" : "Opening gateway"}…` : notice || `${snapshot.activeField === "username" ? "Username" : "Password"} selected`}</strong>
</footer>
</FrontSurface>
);
}
+23 -3
View File
@@ -89,15 +89,35 @@
.companion-feature-grid article strong { font-size: 13px; }
.companion-feature-grid article small { font-size: 11px; }
.companion-login > footer { font-size: 11px; }
.companion-login-keyboard.front-surface--bottom { overflow: hidden; }
.companion-login-keyboard > main { grid-template-rows: 52px minmax(0, 1fr); gap: 6px; padding: 6px 0; overflow: hidden; }
.login-keyboard-fields button { min-height: 52px; }
.login-keyboard-fields small { font-size: 9px; }
.login-keyboard-fields strong { font-size: 15px; }
.login-keyboard-key { font-size: 14px; }
.companion-login-keyboard > footer { min-height: 30px; font-size: 10px; }
.companion-login-keyboard > footer strong { max-width: 52%; font-size: 9px; }
}
@container (min-width: 700px) and (max-width: 1000px) {
.front-surface--top { padding: 12px; }
.front-surface--top :is(p, small, label, dt, dd) { font-size: max(11px, 0.7rem); }
.front-surface--top button { min-height: 44px; }
.login-hero h1 { font-size: 44px; }
.login-form { padding: 20px; }
.login-form h2 { font-size: 25px; }
.login-title { padding-top: 0; }
.login-title__crest { width: 34px; height: 34px; }
.login-title p { margin-top: 7px; font-size: 6px; }
.login-title h1 { font-size: 38px; }
.login-title small { display: none; }
.login-screen { grid-template-rows: minmax(92px, 1fr) auto 24px; }
.login-form { width: min(560px, calc(100% - 28px)); padding: 16px 20px 12px; }
.login-form header { margin-bottom: 7px; }
.login-form header strong { font-size: 23px; }
.login-form > label { margin-top: 5px; }
.login-form > input { height: 38px; }
.login-form__actions { grid-template-columns: 1.15fr 1fr 1fr; margin-top: 9px; }
.login-form__actions .front-button--primary { grid-column: auto; }
.login-form__actions .front-button small { display: none; }
.login-form footer { display: none; }
.character-select-screen { grid-template-columns: 220px minmax(0, 1fr); }
.roster-header, .character-select-screen > .controller-legend { grid-column: 1 / 3; }
.roster-details { display: none; }