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