diff --git a/server/dungeon-leaderboards.mjs b/server/dungeon-leaderboards.mjs index fd532ce7..14438412 100644 --- a/server/dungeon-leaderboards.mjs +++ b/server/dungeon-leaderboards.mjs @@ -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 }; diff --git a/server/dungeon-leaderboards.test.mjs b/server/dungeon-leaderboards.test.mjs index 33676b60..2a23e608 100644 --- a/server/dungeon-leaderboards.test.mjs +++ b/server/dungeon-leaderboards.test.mjs @@ -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 }); diff --git a/server/dungeon-reports.mjs b/server/dungeon-reports.mjs index 2f1c2f4a..95aaffed 100644 --- a/server/dungeon-reports.mjs +++ b/server/dungeon-reports.mjs @@ -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(` diff --git a/server/dungeon-reports.test.mjs b/server/dungeon-reports.test.mjs index dbc03678..93d09c15 100644 --- a/server/dungeon-reports.test.mjs +++ b/server/dungeon-reports.test.mjs @@ -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) => { diff --git a/src/app/OnlineGroupCoordinator.test.ts b/src/app/OnlineGroupCoordinator.test.ts index 1cfaaad6..54c184f9 100644 --- a/src/app/OnlineGroupCoordinator.test.ts +++ b/src/app/OnlineGroupCoordinator.test.ts @@ -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(); }); diff --git a/src/app/OnlineGroupCoordinator.tsx b/src/app/OnlineGroupCoordinator.tsx index d17ac4b3..691136d9 100644 --- a/src/app/OnlineGroupCoordinator.tsx +++ b/src/app/OnlineGroupCoordinator.tsx @@ -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(); diff --git a/src/app/dungeonLeaderboardClient.test.ts b/src/app/dungeonLeaderboardClient.test.ts index a7d1162c..bde71eca 100644 --- a/src/app/dungeonLeaderboardClient.test.ts +++ b/src/app/dungeonLeaderboardClient.test.ts @@ -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); diff --git a/src/app/dungeonLeaderboardClient.ts b/src/app/dungeonLeaderboardClient.ts index 98066085..eca86e81 100644 --- a/src/app/dungeonLeaderboardClient.ts +++ b/src/app/dungeonLeaderboardClient.ts @@ -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; diff --git a/src/app/dungeonShellRuntime.ts b/src/app/dungeonShellRuntime.ts index 920630d8..77dc96e3 100644 --- a/src/app/dungeonShellRuntime.ts +++ b/src/app/dungeonShellRuntime.ts @@ -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.", diff --git a/src/app/dungeonShellRuntimeBridge.ts b/src/app/dungeonShellRuntimeBridge.ts index 65550ed9..e846f40a 100644 --- a/src/app/dungeonShellRuntimeBridge.ts +++ b/src/app/dungeonShellRuntimeBridge.ts @@ -8,6 +8,7 @@ export interface DungeonEntrySettings { readonly difficultyId?: string; readonly bossDifficulty?: number; readonly freshRun?: boolean; + readonly runId?: string; } let installedRuntime: DungeonShellRuntime | null = null; diff --git a/src/app/loginInputStore.test.ts b/src/app/loginInputStore.test.ts new file mode 100644 index 00000000..1658067a --- /dev/null +++ b/src/app/loginInputStore.test.ts @@ -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"); + }); +}); diff --git a/src/app/loginInputStore.ts b/src/app/loginInputStore.ts new file mode 100644 index 00000000..049023b7 --- /dev/null +++ b/src/app/loginInputStore.ts @@ -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 = { + 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((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, + }; +} diff --git a/src/assets/ui/login/frozen-gateway.webp b/src/assets/ui/login/frozen-gateway.webp new file mode 100644 index 00000000..25ced40c Binary files /dev/null and b/src/assets/ui/login/frozen-gateway.webp differ diff --git a/src/content/contentManager.ts b/src/content/contentManager.ts index 65b47fd8..444053f4 100644 --- a/src/content/contentManager.ts +++ b/src/content/contentManager.ts @@ -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 { diff --git a/src/game/biteSizedStockades.test.ts b/src/game/biteSizedStockades.test.ts new file mode 100644 index 00000000..da336964 --- /dev/null +++ b/src/game/biteSizedStockades.test.ts @@ -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(); + 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"); + }); +}); diff --git a/src/game/dungeonCampaignCatalog.test.ts b/src/game/dungeonCampaignCatalog.test.ts index 22398ce7..db23cd37 100644 --- a/src/game/dungeonCampaignCatalog.test.ts +++ b/src/game/dungeonCampaignCatalog.test.ts @@ -72,7 +72,7 @@ const WDT_PLACEMENT_DUNGEON_IDS = [ describe("generated dungeon campaign catalog", () => { it("covers only reviewed five-player instances with loadable assets and combat content", () => { expect(generated.definitions).toHaveLength(54); - expect(DUNGEON_DEFINITIONS).toHaveLength(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 diff --git a/src/game/dungeonCatalog.test.ts b/src/game/dungeonCatalog.test.ts index 074b3bd0..3cdbbd85 100644 --- a/src/game/dungeonCatalog.test.ts +++ b/src/game/dungeonCatalog.test.ts @@ -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()); }); }); diff --git a/src/game/dungeonCatalog.ts b/src/game/dungeonCatalog.ts index 98a3ae28..d7082050 100644 --- a/src/game/dungeonCatalog.ts +++ b/src/game/dungeonCatalog.ts @@ -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((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, diff --git a/src/game/dungeonContentSource.test.ts b/src/game/dungeonContentSource.test.ts new file mode 100644 index 00000000..9b77f896 --- /dev/null +++ b/src/game/dungeonContentSource.test.ts @@ -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"); + }); +}); diff --git a/src/game/dungeonContentSource.ts b/src/game/dungeonContentSource.ts new file mode 100644 index 00000000..3760c7f4 --- /dev/null +++ b/src/game/dungeonContentSource.ts @@ -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> = 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)}`; +} diff --git a/src/game/dungeonLeaderboardCatalog.test.ts b/src/game/dungeonLeaderboardCatalog.test.ts index b27455b3..9efce6a0 100644 --- a/src/game/dungeonLeaderboardCatalog.test.ts +++ b/src/game/dungeonLeaderboardCatalog.test.ts @@ -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`); diff --git a/src/game/dungeonLeaderboardRules.ts b/src/game/dungeonLeaderboardRules.ts new file mode 100644 index 00000000..59130ff1 --- /dev/null +++ b/src/game/dungeonLeaderboardRules.ts @@ -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)); +} diff --git a/src/game/dungeonPipelineFoundation.test.ts b/src/game/dungeonPipelineFoundation.test.ts index d0c1712d..852f0392 100644 --- a/src/game/dungeonPipelineFoundation.test.ts +++ b/src/game/dungeonPipelineFoundation.test.ts @@ -18,7 +18,7 @@ describe("dungeon pipeline runtime foundation", () => { }); it("registers every reviewed instanced dungeon with WC first", () => { - expect(DUNGEON_DEFINITIONS).toHaveLength(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); diff --git a/src/game/dungeonRegistry.ts b/src/game/dungeonRegistry.ts index 7382200f..0de5adaf 100644 --- a/src/game/dungeonRegistry.ts +++ b/src/game/dungeonRegistry.ts @@ -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>; +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> = 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(); +const sessionDungeonDefinitions = new Map(); 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>)[id]; + const configured = (availability.enabled as Readonly>)[dungeonContentSourceId(id)]; if (definition.expansion !== "runewaker") return configured === true; if (!dungeonPackagePassesOfflineGate(definition)) return false; if (configured !== undefined) return configured; diff --git a/src/game/dungeonReleaseStage.ts b/src/game/dungeonReleaseStage.ts index 8cd08322..61089ab7 100644 --- a/src/game/dungeonReleaseStage.ts +++ b/src/game/dungeonReleaseStage.ts @@ -8,7 +8,7 @@ export const DUNGEON_RELEASE_STAGE_LABELS: Readonly 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((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, }, }), diff --git a/src/game/dungeonSession.ts b/src/game/dungeonSession.ts index e67e279f..f77efadf 100644 --- a/src/game/dungeonSession.ts +++ b/src/game/dungeonSession.ts @@ -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 ?? [], diff --git a/src/game/dungeons/biteSizedStockades.ts b/src/game/dungeons/biteSizedStockades.ts new file mode 100644 index 00000000..c52944e8 --- /dev/null +++ b/src/game/dungeons/biteSizedStockades.ts @@ -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.", + ], + }, + }; +} diff --git a/src/game/generated/dungeonLeaderboardCatalog.generated.json b/src/game/generated/dungeonLeaderboardCatalog.generated.json index 1d5d52f5..18bb5659 100644 --- a/src/game/generated/dungeonLeaderboardCatalog.generated.json +++ b/src/game/generated/dungeonLeaderboardCatalog.generated.json @@ -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", diff --git a/src/game/mobPopulation.test.ts b/src/game/mobPopulation.test.ts index bc4f461c..81809955 100644 --- a/src/game/mobPopulation.test.ts +++ b/src/game/mobPopulation.test.ts @@ -108,7 +108,7 @@ describe("looped mob path sampling", () => { }); it("keeps every packaged dungeon patrol on a usable loop", () => { - expect(DUNGEON_DEFINITIONS).toHaveLength(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); diff --git a/src/game/partyNavigationCatalog.test.ts b/src/game/partyNavigationCatalog.test.ts index cd06b80d..51fa9808 100644 --- a/src/game/partyNavigationCatalog.test.ts +++ b/src/game/partyNavigationCatalog.test.ts @@ -84,7 +84,7 @@ describe("party navigation fleet safety", () => { const enabled = []; const fallback = []; - expect(DUNGEON_DEFINITIONS).toHaveLength(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); }); diff --git a/src/game/wowDungeonEnvironment.test.ts b/src/game/wowDungeonEnvironment.test.ts index 657fb3c4..15437be1 100644 --- a/src/game/wowDungeonEnvironment.test.ts +++ b/src/game/wowDungeonEnvironment.test.ts @@ -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); diff --git a/src/platform/authoritativeDualScreenRuntime.ts b/src/platform/authoritativeDualScreenRuntime.ts index bbda1da1..737fa48a 100644 --- a/src/platform/authoritativeDualScreenRuntime.ts +++ b/src/platform/authoritativeDualScreenRuntime.ts @@ -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(); diff --git a/src/platform/dualScreenDps.test.ts b/src/platform/dualScreenDps.test.ts index 08da3bb3..6b11e677 100644 --- a/src/platform/dualScreenDps.test.ts +++ b/src/platform/dualScreenDps.test.ts @@ -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, + ); + 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(); + }); }); diff --git a/src/platform/dualScreenSync.test.ts b/src/platform/dualScreenSync.test.ts index 4c0a4095..5b23b662 100644 --- a/src/platform/dualScreenSync.test.ts +++ b/src/platform/dualScreenSync.test.ts @@ -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", () => { diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts index 25a9480f..e6d3a3f0 100644 --- a/src/platform/dualScreenSync.ts +++ b/src/platform/dualScreenSync.ts @@ -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 = {}; diff --git a/src/scene/dungeonMaterialAudit.test.ts b/src/scene/dungeonMaterialAudit.test.ts index 101808e4..36371a9c 100644 --- a/src/scene/dungeonMaterialAudit.test.ts +++ b/src/scene/dungeonMaterialAudit.test.ts @@ -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", () => { diff --git a/src/styles.css b/src/styles.css index 0ba96909..742536ce 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 { diff --git a/src/ui/BossGuidePanel.tsx b/src/ui/BossGuidePanel.tsx index adb716cc..7f1df297 100644 --- a/src/ui/BossGuidePanel.tsx +++ b/src/ui/BossGuidePanel.tsx @@ -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(() => { - 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() {