Update Healer Man server

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

No files matched your search

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