Update Healer Man server

This commit is contained in:
phenom committed 2026-09-27 14:31:15 -04:00
1 parent ab1434a3b8
commit e284f4fc76
60 files changed
+2704 -118

No files matched your search

+4 -1
View File
@@ -1182,7 +1182,7 @@ export function openGameDatabase(options = {}) {
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
...(() => {
const races = createDungeonRaceRepository(database, { ErrorType: GameDatabaseError });
const races = createDungeonRaceRepository(database, { ErrorType: GameDatabaseError, isGameMaster });
return {
getDungeonRaceMatch: races.getMatch,
createDungeonRaceMatch: races.createMatch,
@@ -1190,6 +1190,9 @@ export function openGameDatabase(options = {}) {
startDungeonRace: races.start,
settleDungeonRace: races.settleMatch,
completeDungeonRaceRun: races.completeRaceRun,
savePartialDungeonRaceCombatLog: races.savePartialCombatLog,
getDungeonRaceCombatLog: races.getCombatLog,
listDungeonRaceCombatLogs: races.listCombatLogs,
replaceDungeonRaceWithBot: races.replaceWithBot,
voidDungeonRace: races.void,
dungeonRaceRecordForAccount: races.recordForAccount,
+84
View File
@@ -0,0 +1,84 @@
export const MAX_RACE_COMBAT_LOG_BYTES = 4 * 1024 * 1024;
const kinds = new Set(["damage", "healing", "absorb", "resource", "aura", "cast",
"interrupt", "death", "resurrection"]);
function text(value, maximum = 128) {
return typeof value === "string" && value.length > 0 && value.length <= maximum
&& !/[\u0000-\u001f\u007f]/.test(value);
}
function amount(value, maximum = 1_000_000_000) {
return Number.isSafeInteger(value) && value >= 0 && value <= maximum;
}
/** Client-reported diagnostics only; race outcome still comes from the boss clear. */
export function normalizeRaceCombatLog(input, { runId, dungeonId }, ErrorType) {
const invalid = () => { throw new ErrorType("Choose a valid race combat log.", 400, "invalid_race_combat_log"); };
if (!input || typeof input !== "object" || input.version !== 1 || input.runId !== runId
|| typeof input.completed !== "boolean"
|| input.dungeonId !== dungeonId || !amount(input.durationMs, 2 * 60 * 60_000)
|| !amount(input.droppedEventCount) || !amount(input.droppedKillCount)
|| !Array.isArray(input.events) || input.events.length > 10_000
|| !Array.isArray(input.kills) || input.kills.length > 1_000
|| !Array.isArray(input.packs) || input.packs.length > 1_000
|| !Array.isArray(input.party) || input.party.length > 5
|| !Array.isArray(input.partyDamage) || input.partyDamage.length > 5) invalid();
const events = input.events.map((event) => {
if (!event || !amount(event.atMs, 2 * 60 * 60_000) || !kinds.has(event.kind)
|| !text(event.source) || !text(event.target) || (event.ability !== undefined && !text(event.ability))
|| !amount(event.raw) || !amount(event.effective) || !amount(event.overheal)
|| !amount(event.absorbed) || typeof event.critical !== "boolean"
|| (event.outcome !== undefined && !text(event.outcome))) invalid();
return { atMs: event.atMs, kind: event.kind, source: event.source, target: event.target,
...(event.ability ? { ability: event.ability } : {}), raw: event.raw,
effective: event.effective, overheal: event.overheal, absorbed: event.absorbed,
critical: event.critical, ...(event.outcome ? { outcome: event.outcome } : {}) };
});
const kills = input.kills.map((kill) => {
if (!kill || !text(kill.id) || !text(kill.name) || typeof kill.boss !== "boolean"
|| !amount(kill.maxHealth) || !amount(kill.engagedAtMs, 2 * 60 * 60_000)
|| (kill.firstDamageAtMs !== null && !amount(kill.firstDamageAtMs, 2 * 60 * 60_000))
|| !amount(kill.diedAtMs, 2 * 60 * 60_000) || kill.diedAtMs < kill.engagedAtMs
|| !amount(kill.durationMs, 2 * 60 * 60_000)) invalid();
return { id: kill.id, name: kill.name, boss: kill.boss, maxHealth: kill.maxHealth,
engagedAtMs: kill.engagedAtMs, firstDamageAtMs: kill.firstDamageAtMs,
diedAtMs: kill.diedAtMs, durationMs: kill.durationMs };
});
const party = input.party.map((member) => {
if (!member || !text(member.actorId) || !text(member.label) || !text(member.classId)
|| !amount(member.level, 80) || member.level < 1
|| !["tank", "healer", "damage"].includes(member.role)) invalid();
return { actorId: member.actorId, label: member.label, classId: member.classId,
level: member.level, role: member.role };
});
const partyIds = new Set(party.map((member) => member.actorId));
if (partyIds.size !== party.length) invalid();
const partyDamage = input.partyDamage.map((entry) => {
if (!entry || !partyIds.has(entry.actorId) || !amount(entry.damage)) invalid();
return { actorId: entry.actorId, damage: entry.damage };
});
if (new Set(partyDamage.map((entry) => entry.actorId)).size !== partyDamage.length) invalid();
const knownMobIds = new Set(kills.map((kill) => kill.id));
const packs = input.packs.map((pack) => {
if (!pack || !amount(pack.startedAtMs, 2 * 60 * 60_000)
|| !amount(pack.endedAtMs, 2 * 60 * 60_000) || pack.endedAtMs < pack.startedAtMs
|| !amount(pack.durationMs, 2 * 60 * 60_000)
|| !Array.isArray(pack.mobIds) || pack.mobIds.length < 1 || pack.mobIds.length > 1_000
|| pack.mobIds.some((id) => !knownMobIds.has(id))
|| !Array.isArray(pack.partyDamage) || pack.partyDamage.length > 5) invalid();
const damage = pack.partyDamage.map((entry) => {
if (!entry || !partyIds.has(entry.actorId) || !amount(entry.damage)) invalid();
return { actorId: entry.actorId, damage: entry.damage };
});
return { startedAtMs: pack.startedAtMs, endedAtMs: pack.endedAtMs,
durationMs: pack.durationMs, mobIds: [...pack.mobIds], partyDamage: damage };
});
const normalized = { version: 1, runId, dungeonId, durationMs: input.durationMs,
completed: input.completed,
events, droppedEventCount: input.droppedEventCount, kills, packs,
droppedKillCount: input.droppedKillCount, party, partyDamage };
if (Buffer.byteLength(JSON.stringify(normalized)) > MAX_RACE_COMBAT_LOG_BYTES) invalid();
return normalized;
}
+16 -2
View File
@@ -1,5 +1,6 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import bots from "../src/game/dungeonRaceBotCatalog.json" with { type: "json" };
import affixes from "../src/game/dungeonRaceAffixes.json" with { type: "json" };
export const RACE_QUEUE_WAIT_MS = 5_000;
export const RACE_PREPARE_MS = 5 * 60_000;
@@ -31,6 +32,19 @@ export function hashRaceSeed(value) {
return hash >>> 0;
}
export function rollRaceAffixes(seed) {
return [
affixes.kill[hashRaceSeed(`${seed}:kill-affix`) % affixes.kill.length].id,
affixes.movement[hashRaceSeed(`${seed}:movement-affix`) % affixes.movement.length].id,
];
}
export function raceAffixPace(pair = []) {
return pair.reduce((factor, id) => factor * (
[...affixes.kill, ...affixes.movement].find((entry) => entry.id === id)?.botMultiplier ?? 1
), 1);
}
export function botTierForRecord(wins, losses, seed) {
const total = wins + losses;
const rate = total < 5 ? 0.5 : wins / total;
@@ -39,11 +53,11 @@ export function botTierForRecord(wins, losses, seed) {
return bots.tiers[Math.max(0, Math.min(3, base + (roll === 0 ? -1 : roll === 3 ? 1 : 0)))].id;
}
export function botOpponent(seed, tierId, source = null) {
export function botOpponent(seed, tierId, source = null, affixIds = []) {
const tier = bots.tiers.find((entry) => entry.id === tierId) ?? bots.tiers[1];
const resolvedSource = source ?? (hashRaceSeed(`${seed}:source`) % 2 ? "rom" : "wow");
const variance = 0.92 + (hashRaceSeed(`${seed}:pace`) % 161) / 1000;
const finishMs = Math.round(bots.baselineMs * tier.multiplier * variance);
const finishMs = Math.round(bots.baselineMs * tier.multiplier * variance * raceAffixPace(affixIds));
return { type: "bot", name: `${tier.label} ${resolvedSource === "rom" ? "RuneWaker" : "Azeroth"} Team`,
tier: tier.id, source: resolvedSource, roster: bots.teams[resolvedSource], finishMs,
milestones: bots.milestones.map((entry) => ({ ...entry, atMs: Math.round(finishMs * entry.at) })) };
+9 -8
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { GameDatabaseError } from "./database.mjs";
import { botOpponent, botProgress, botTierForRecord, compatibleDungeons, hashRaceSeed, raceDungeons,
import { botOpponent, botProgress, botTierForRecord, compatibleDungeons, hashRaceSeed, raceDungeons, rollRaceAffixes,
RACE_COUNTDOWN_MS, RACE_DISCONNECT_MS, RACE_MAX_MS, RACE_PREPARE_MS, RACE_QUEUE_WAIT_MS } from "./dungeon-race-rules.mjs";
export function createDungeonRaceService({ database, now = () => Date.now(), autoPump = true } = {}) {
@@ -30,7 +30,7 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
const elapsedMs = match.startAt ? Math.max(0, timestamp - match.startAt) : 0;
return { status: match.status === "active" && timestamp < match.startAt ? "countdown" : match.status,
serverTime: timestamp, matchId: match.id, seed: match.seed, dungeonId: match.dungeonId,
difficultyId: match.difficultyId, bossDifficulty: match.bossDifficulty,
difficultyId: match.difficultyId, bossDifficulty: match.bossDifficulty, affixIds: match.affixIds,
prepareDeadline: match.createdAt + RACE_PREPARE_MS, startAt: match.startAt,
ready: self?.readyAt != null, opponent: match.bot ?? (rival ? { type: "human", name: rival.username,
source: rival.source, ready: rival.readyAt != null, finished: rival.clearAt != null } : null),
@@ -47,19 +47,20 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
const updated = database.settleDungeonRace(match.id, winnerSide, timestamp);
if (updated) matches.set(match.id, updated);
}
function makeBot(seed, accountId) {
function makeBot(seed, accountId, affixIds) {
const { wins, losses } = database.dungeonRaceRecordForAccount(accountId, now());
return botOpponent(seed, botTierForRecord(wins, losses, seed));
return botOpponent(seed, botTierForRecord(wins, losses, seed), null, affixIds);
}
function createMatch(left, right, choices, timestamp) {
const id = `race-${randomUUID()}`;
const dungeon = choices[hashRaceSeed(id) % choices.length];
const bot = right ? null : makeBot(id, left.accountId);
const affixIds = rollRaceAffixes(id);
const bot = right ? null : makeBot(id, left.accountId, affixIds);
const participants = [left, ...(right ? [right] : [{ side: 1, accountId: null,
characterId: null, username: bot.name, source: bot.source, role: null }])]
.map((entry, side) => ({ side, accountId: entry.accountId, characterId: entry.characterId,
username: entry.username, source: entry.source, role: entry.role }));
const match = database.createDungeonRaceMatch({ id, dungeonId: dungeon.id, seed: id,
const match = database.createDungeonRaceMatch({ id, dungeonId: dungeon.id, seed: id, affixIds,
difficultyId: dungeon.defaultDifficultyId, createdAt: timestamp, participants, bot });
matches.set(id, match);
for (const participant of participants) if (participant.accountId) {
@@ -74,7 +75,7 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
if (leaving?.accountId) { accountMatch.delete(leaving.accountId); lastSeen.delete(leaving.accountId); }
const surviving = match.participants.find((entry) => entry.side !== side && entry.accountId);
if (!surviving) { matches.set(match.id, database.voidDungeonRace(match.id, timestamp)); return; }
const bot = makeBot(match.seed, surviving.accountId);
const bot = makeBot(match.seed, surviving.accountId, match.affixIds);
const updated = database.replaceDungeonRaceWithBot(match.id, side, bot);
matches.set(match.id, updated);
if (updated.participants.find((entry) => entry.accountId === surviving.accountId)?.readyAt != null) {
@@ -163,7 +164,7 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
if (input?.runId !== match.seed)
throw new GameDatabaseError("This clear belongs to a different race run.", 400, "race_run_mismatch");
pump();
const updated = database.completeDungeonRaceRun(id, account.id, input.bossIds, now());
const updated = database.completeDungeonRaceRun(id, account.id, input.bossIds, now(), input.combatLog ?? null);
matches.set(id, updated);
if (updated.status === "active") settle(updated, updated.participants.find((entry) => entry.accountId === account.id).side, now());
return publicState(account.id);
+76 -1
View File
@@ -5,7 +5,7 @@ import os from "node:os";
import path from "node:path";
import { openGameDatabase } from "./database.mjs";
import { createDungeonRaceService } from "./dungeon-race-service.mjs";
import { botOpponent, botProgress, botTierForRecord, hashRaceSeed } from "./dungeon-race-rules.mjs";
import { botOpponent, botProgress, botTierForRecord, hashRaceSeed, raceAffixPace, rollRaceAffixes } from "./dungeon-race-rules.mjs";
import { createGameServer } from "./server.mjs";
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
import { DatabaseSync } from "node:sqlite";
@@ -20,6 +20,19 @@ const bossIdsFor = (match) => {
? `${match.seed}:boss` : match.seed;
return [ids[hashRaceSeed(seed) % ids.length]];
};
const combatLogFor = (match, completed) => ({
version: 1, runId: match.seed, dungeonId: match.dungeonId, durationMs: 1_000, completed,
events: [{ atMs: 100, kind: "damage", source: "player", target: "mob-1",
ability: "test-aoe", raw: 100, effective: 90, overheal: 0, absorbed: 10, critical: false }],
droppedEventCount: 0,
kills: [{ id: "mob-1", name: "Mob One", boss: false, maxHealth: 90,
engagedAtMs: 100, firstDamageAtMs: 100, diedAtMs: 500, durationMs: 400 }],
packs: [{ startedAtMs: 100, endedAtMs: 500, durationMs: 400, mobIds: ["mob-1"],
partyDamage: [{ actorId: "player", damage: 90 }] }],
droppedKillCount: 0,
party: [{ actorId: "player", label: "Player", classId: "priest", level: 60, role: "healer" }],
partyDamage: [{ actorId: "player", damage: 90 }],
});
test("NPC tiers follow rolling records with seeded adjacent variation and milestones", () => {
for (const seed of ["race-tier-a", "race-tier-b", "race-tier-c", "race-tier-d"]) {
@@ -34,6 +47,17 @@ test("NPC tiers follow rolling records with seeded adjacent variation and milest
}
});
test("a shared seed rolls one stable pair and scales an NPC's entire progress model", () => {
const seed = "race-affix-parity";
const pair = rollRaceAffixes(seed);
assert.deepEqual(pair, rollRaceAffixes(seed));
assert.equal(pair.length, 2);
const plain = botOpponent(seed, "regular", "wow");
const tuned = botOpponent(seed, "regular", "wow", pair);
assert.equal(tuned.finishMs, Math.round(plain.finishMs * raceAffixPace(pair)));
assert.equal(tuned.milestones.at(-1).atMs, tuned.finishMs);
});
test("migrates existing race matches onto their seeded boss boards", () => {
const database = new DatabaseSync(":memory:");
try {
@@ -58,6 +82,7 @@ test("migrates existing race matches onto their seeded boss boards", () => {
const seed = `race-old-${dungeonId}`;
assert.equal(database.prepare("SELECT boss_id FROM dungeon_race_matches WHERE id = ?").get(seed).boss_id,
bossIdsFor({ dungeonId, difficultyId: dungeon.defaultDifficultyId, seed })[0]);
assert.equal(database.prepare("SELECT affix_ids_json FROM dungeon_race_matches WHERE id = ?").get(seed).affix_ids_json, "[]");
}
} finally { database.close(); }
});
@@ -87,6 +112,8 @@ test("random and specific tickets pair across WoW and RoM, then a later losing c
dungeonId: dungeon.id });
assert.equal(paired.status, "preparing");
assert.equal(service.state(wow).seed, paired.seed);
assert.deepEqual(service.state(wow).affixIds, paired.affixIds);
assert.deepEqual(paired.affixIds, rollRaceAffixes(paired.seed));
assert.equal(paired.opponent.source, "wow");
service.ready(wow, paired.matchId);
assert.equal(service.ready(rom, paired.matchId).status, "countdown");
@@ -104,6 +131,8 @@ test("random and specific tickets pair across WoW and RoM, then a later losing c
assert.equal(database.listDungeonRaceLeaderboard({ view: "wins", period: "30d" }, time()).entries[0].username, "race_wow");
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id,
bossId: bossIds[0] }, time()).total, 2);
assert.deepEqual(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id,
bossId: bossIds[0] }, time()).entries[0].affixIds, paired.affixIds);
const otherBossId = dungeon.difficulties[0].finalBossIds.find((id) => id !== bossIds[0]);
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId: dungeon.id,
bossId: otherBossId }, time()).total, 0);
@@ -112,6 +141,34 @@ test("random and specific tickets pair across WoW and RoM, then a later losing c
assert.equal(service.finish(rom, paired.matchId, { runId: paired.seed, bossIds }).clearDurationMs, late.clearDurationMs);
}));
test("records a partial race log, upgrades it on clear, and scopes review to its participant", async () => fixture(async ({ database, service, player, advance, time }) => {
const owner = await player("race_log_owner");
const stranger = await player("race_log_stranger");
service.queue(owner, { characterId: "race_log_owner-character", role: "healer", pool: "authored",
dungeonId: dungeon.id });
advance(5_000);
const match = service.state(owner);
service.ready(owner, match.matchId);
advance(3_000);
const partial = combatLogFor(match, false);
assert.deepEqual(database.savePartialDungeonRaceCombatLog(match.matchId, owner.id, partial, time()),
{ ok: true, matchId: match.matchId, completed: false });
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).completed, false);
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).log.packs[0].durationMs, 400);
assert.throws(() => database.getDungeonRaceCombatLog(stranger, match.matchId),
{ code: "race_combat_log_not_found" });
assert.throws(() => database.listDungeonRaceCombatLogs(owner), { code: "gm_required" });
assert.throws(() => database.savePartialDungeonRaceCombatLog(match.matchId, stranger.id,
partial, time()), { code: "race_not_found" });
assert.throws(() => service.finish(owner, match.matchId, { runId: match.seed,
bossIds: bossIdsFor(match), combatLog: { ...combatLogFor(match, true), events: [{}] } }),
{ code: "invalid_race_combat_log" });
service.finish(owner, match.matchId, { runId: match.seed, bossIds: bossIdsFor(match),
combatLog: combatLogFor(match, true) });
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).completed, true);
assert.equal(database.getDungeonRaceCombatLog(owner, match.matchId).log.events[0].ability, "test-aoe");
}));
test("both randomized creature pools validate and rank their cross-dungeon final boss", async () => fixture(async ({ database, service, player, advance, time }) => {
for (const [index, dungeonId] of ["randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available"].entries()) {
@@ -224,6 +281,24 @@ test("production HTTP routes expose ranked queue and public boards without shelv
assert.equal((await fetch(`${base}/api/dungeon-races/state`, { headers })).status, 200);
assert.equal((await fetch(`${base}/api/dungeon-races/leaderboard?period=30d`)).status, 200);
assert.equal((await fetch(`${base}/api/dungeon-races/queue`, { method: "DELETE", headers })).status, 200);
const matchId = "race-http-combat-log";
runtime.database.createDungeonRaceMatch({ id: matchId, dungeonId: dungeon.id, seed: matchId,
difficultyId: dungeon.defaultDifficultyId, createdAt: Date.now() - 2_000,
participants: [{ side: 0, accountId: auth.account.id, characterId: "http-character",
username: auth.account.username, source: "wow", role: "healer" },
{ side: 1, accountId: null, characterId: null, username: "Bot", source: "wow", role: null }],
bot: null });
runtime.database.startDungeonRace(matchId, Date.now() - 1_000);
const logResponse = await fetch(`${base}/api/dungeon-races/${matchId}/combat-log`, {
method: "POST", headers, body: JSON.stringify({ combatLog: combatLogFor({
seed: matchId, dungeonId: dungeon.id }, false) }),
});
assert.equal(logResponse.status, 200);
const review = await fetch(`${base}/api/dungeon-races/${matchId}/combat-log`, { headers });
assert.equal(review.status, 200);
assert.equal((await review.json()).log.kills[0].durationMs, 400);
assert.equal((await fetch(`${base}/api/dungeon-races/${matchId}/combat-log`)).status, 401);
assert.equal((await fetch(`${base}/api/gm/dungeon-races/combat-logs`, { headers })).status, 403);
} finally {
await new Promise((resolve) => runtime.server.close(resolve));
rmSync(dataDir, { recursive: true, force: true });
+90 -7
View File
@@ -1,5 +1,6 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import { hashRaceSeed, RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
import { normalizeRaceCombatLog } from "./dungeon-race-combat-log.mjs";
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
@@ -10,7 +11,7 @@ function raceBossId(dungeonId, difficultyId, seed) {
return finalIds?.length ? finalIds[hashRaceSeed(objectiveSeed) % finalIds.length] : null;
}
export function createDungeonRaceRepository(database, { ErrorType }) {
export function createDungeonRaceRepository(database, { ErrorType, isGameMaster = () => false }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_race_matches (
id TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL, seed TEXT NOT NULL,
@@ -32,10 +33,26 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
ON dungeon_race_matches(status, settled_at, dungeon_id);
CREATE INDEX IF NOT EXISTS dungeon_race_account_idx
ON dungeon_race_participants(account_id, outcome, clear_at);
CREATE TABLE IF NOT EXISTS dungeon_race_combat_logs (
match_id TEXT NOT NULL REFERENCES dungeon_race_matches(id) ON DELETE CASCADE,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
received_at INTEGER NOT NULL,
event_count INTEGER NOT NULL,
kill_count INTEGER NOT NULL,
dropped_event_count INTEGER NOT NULL,
completed INTEGER NOT NULL CHECK (completed IN (0, 1)),
payload_json TEXT NOT NULL,
PRIMARY KEY (match_id, account_id)
) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_race_combat_log_recent_idx
ON dungeon_race_combat_logs(received_at DESC);
`);
if (!database.prepare("PRAGMA table_info(dungeon_race_matches)").all().some((column) => column.name === "boss_id")) {
database.exec("ALTER TABLE dungeon_race_matches ADD COLUMN boss_id TEXT");
}
if (!database.prepare("PRAGMA table_info(dungeon_race_matches)").all().some((column) => column.name === "affix_ids_json")) {
database.exec("ALTER TABLE dungeon_race_matches ADD COLUMN affix_ids_json TEXT NOT NULL DEFAULT '[]'");
}
const missingBosses = database.prepare("SELECT id, dungeon_id, difficulty_id, seed FROM dungeon_race_matches WHERE boss_id IS NULL").all();
const updateBoss = database.prepare("UPDATE dungeon_race_matches SET boss_id = ? WHERE id = ?");
for (const match of missingBosses) {
@@ -49,8 +66,8 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
const matchById = database.prepare("SELECT * FROM dungeon_race_matches WHERE id = ?");
const participantsById = database.prepare("SELECT * FROM dungeon_race_participants WHERE match_id = ? ORDER BY side");
const insertMatch = database.prepare(`INSERT INTO dungeon_race_matches
(id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json, boss_id)
VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?, ?)`);
(id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json, boss_id, affix_ids_json)
VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?, ?, ?)`);
const insertParticipant = database.prepare(`INSERT INTO dungeon_race_participants
(match_id, side, account_id, character_id, username, source, role)
VALUES (?, ?, ?, ?, ?, ?, ?)`);
@@ -69,6 +86,14 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
const updateVoid = database.prepare(`UPDATE dungeon_race_matches
SET status = 'void', settled_at = ? WHERE id = ? AND status IN ('preparing', 'active')`);
const updateOutcome = database.prepare(`UPDATE dungeon_race_participants SET outcome = ? WHERE match_id = ? AND side = ?`);
const insertCombatLog = database.prepare(`INSERT INTO dungeon_race_combat_logs
(match_id, account_id, received_at, event_count, kill_count, dropped_event_count, completed, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(match_id, account_id) DO UPDATE SET received_at = excluded.received_at,
event_count = excluded.event_count, kill_count = excluded.kill_count,
dropped_event_count = excluded.dropped_event_count, completed = excluded.completed,
payload_json = excluded.payload_json
WHERE dungeon_race_combat_logs.completed = 0`);
function transaction(action) {
database.exec("BEGIN IMMEDIATE");
@@ -83,6 +108,7 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
status: match.status, createdAt: match.created_at, startAt: match.start_at,
settledAt: match.settled_at, winnerSide: match.winner_side,
bot: match.bot_json ? JSON.parse(match.bot_json) : null,
affixIds: JSON.parse(match.affix_ids_json ?? "[]"),
participants: participantsById.all(id).map((row) => ({ side: row.side, accountId: row.account_id,
characterId: row.character_id, username: row.username, source: row.source, role: row.role,
readyAt: row.ready_at, clearAt: row.clear_at, durationMs: row.duration_ms, outcome: row.outcome })) };
@@ -90,7 +116,8 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
function createMatch(match) {
transaction(() => {
insertMatch.run(match.id, match.dungeonId, match.seed, match.difficultyId, match.createdAt,
match.bot ? JSON.stringify(match.bot) : null, raceBossId(match.dungeonId, match.difficultyId, match.seed));
match.bot ? JSON.stringify(match.bot) : null, raceBossId(match.dungeonId, match.difficultyId, match.seed),
JSON.stringify(match.affixIds ?? []));
for (const participant of match.participants) insertParticipant.run(match.id, participant.side,
participant.accountId, participant.characterId, participant.username, participant.source, participant.role);
});
@@ -105,7 +132,7 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
return getMatch(id);
});
}
function completeRaceRun(id, accountId, bossIds, timestamp) {
function completeRaceRun(id, accountId, bossIds, timestamp, combatLog = null) {
const match = getMatch(id);
if (!match || !match.participants.some((entry) => entry.accountId === accountId))
throw new ErrorType("That race is not yours.", 404, "race_not_found");
@@ -118,11 +145,59 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
|| !bossIds.some((value) => finalIds.includes(value))
|| (selectedBossId && (bossIds.length !== 1 || bossIds[0] !== selectedBossId)))
throw new ErrorType("Defeat the race's final boss before reporting a clear.", 400, "invalid_race_clear");
const normalizedLog = combatLog === null ? null
: normalizeRaceCombatLog(combatLog, { runId: match.seed, dungeonId: match.dungeonId }, ErrorType);
if (normalizedLog && !normalizedLog.completed)
throw new ErrorType("A race clear needs a completed combat log.", 400, "invalid_race_combat_log");
return transaction(() => {
updateClear.run(timestamp, Math.max(1, timestamp - match.startAt), id, accountId);
if (normalizedLog) insertCombatLog.run(id, accountId, timestamp,
normalizedLog.events.length, normalizedLog.kills.length,
normalizedLog.droppedEventCount, 1, JSON.stringify(normalizedLog));
return getMatch(id);
});
}
function savePartialCombatLog(id, accountId, combatLog, timestamp) {
const match = getMatch(id);
const participant = match?.participants.find((entry) => entry.accountId === accountId);
if (!participant) throw new ErrorType("That race is not yours.", 404, "race_not_found");
if (!match.startAt || timestamp < match.startAt || timestamp - match.startAt > 24 * 60 * 60_000)
throw new ErrorType("This race is not accepting combat logs.", 409, "race_combat_log_unavailable");
const normalized = normalizeRaceCombatLog(combatLog,
{ runId: match.seed, dungeonId: match.dungeonId }, ErrorType);
if (normalized.completed)
throw new ErrorType("Report a clear to submit a complete combat log.", 400, "invalid_race_combat_log");
insertCombatLog.run(id, accountId, timestamp, normalized.events.length,
normalized.kills.length, normalized.droppedEventCount, 0, JSON.stringify(normalized));
return { ok: true, matchId: id, completed: false };
}
function getCombatLog(account, id, requestedAccountId = null) {
const ownerId = isGameMaster(account) && requestedAccountId ? requestedAccountId : account.id;
const row = database.prepare(`SELECT log.*, match.dungeon_id, match.boss_id,
account.username FROM dungeon_race_combat_logs log
JOIN dungeon_race_matches match ON match.id = log.match_id
JOIN accounts account ON account.id = log.account_id
WHERE log.match_id = ? AND log.account_id = ?`).get(id, ownerId);
if (!row) throw new ErrorType("That race combat log was not found.", 404, "race_combat_log_not_found");
return { matchId: row.match_id, accountId: row.account_id, username: row.username,
dungeonId: row.dungeon_id, bossId: row.boss_id, receivedAt: row.received_at,
completed: Boolean(row.completed), log: JSON.parse(row.payload_json) };
}
function listCombatLogs(account, limit = 20) {
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
throw new ErrorType("Choose a valid combat log limit.", 400, "invalid_race_combat_log_limit");
return database.prepare(`SELECT log.match_id AS matchId, log.account_id AS accountId,
account.username, match.dungeon_id AS dungeonId, match.boss_id AS bossId,
log.received_at AS receivedAt, log.event_count AS eventCount,
log.kill_count AS killCount, log.dropped_event_count AS droppedEventCount,
log.completed AS completed
FROM dungeon_race_combat_logs log
JOIN dungeon_race_matches match ON match.id = log.match_id
JOIN accounts account ON account.id = log.account_id
ORDER BY log.received_at DESC LIMIT ?`).all(limit)
.map((row) => ({ ...row, completed: Boolean(row.completed) }));
}
function replaceWithBot(id, side, bot) {
transaction(() => {
deleteParticipant.run(id, side);
@@ -172,18 +247,26 @@ export function createDungeonRaceRepository(database, { ErrorType }) {
const all = database.prepare(query).all(...args);
let lastValue = null;
let rank = 0;
const bestAffixes = view === "times" ? database.prepare(`SELECT m.affix_ids_json AS affixIdsJson
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
WHERE p.account_id = ? AND m.dungeon_id = ? AND m.boss_id = ?
AND p.duration_ms = ? AND p.clear_at >= ?
ORDER BY p.clear_at ASC LIMIT 1`) : null;
const ranked = all.map((row, index) => {
const value = view === "wins" ? Number(row.wins) : Number(row.durationMs);
if (value !== lastValue) rank = index + 1;
lastValue = value;
return { ...row, rank, ...(view === "wins"
? { wins: Number(row.wins), losses: Number(row.losses) }
: { durationMs: Number(row.durationMs) }) };
: { durationMs: Number(row.durationMs), affixIds: JSON.parse(bestAffixes.get(
row.accountId, dungeonId, selectedBossId, row.durationMs, since)?.affixIdsJson ?? "[]") }) };
});
return { view, period, dungeonId, bossId: selectedBossId,
total: ranked.length, entries: ranked.slice(offset, offset + limit) };
}
return { getMatch, createMatch, ready: (id, accountId, at) => (updateReady.run(at, id, accountId), getMatch(id)),
start: (id, at) => (updateStart.run(at, id), getMatch(id)), settleMatch, completeRaceRun,
replaceWithBot, void: (id, at) => (updateVoid.run(at, id), getMatch(id)), recordForAccount, leaderboard };
savePartialCombatLog,
getCombatLog, listCombatLogs, replaceWithBot,
void: (id, at) => (updateVoid.run(at, id), getMatch(id)), recordForAccount, leaderboard };
}
+22 -1
View File
@@ -331,12 +331,33 @@ export function createGameServer(options = {}) {
json(response, 200, dungeonRaceService.state(account));
return;
}
if (url.pathname === "/api/gm/dungeon-races/combat-logs" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
json(response, 200, { entries: gameDatabase.listDungeonRaceCombatLogs(account,
Number(url.searchParams.get("limit") ?? 20)) });
return;
}
const raceCombatLogMatch = /^\/api\/dungeon-races\/(race-[A-Za-z0-9-]+)\/combat-log$/.exec(url.pathname);
if (raceCombatLogMatch && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
const requestedAccountId = url.searchParams.get("accountId");
json(response, 200, gameDatabase.getDungeonRaceCombatLog(account, raceCombatLogMatch[1],
requestedAccountId));
return;
}
if (raceCombatLogMatch && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
const body = await readJson(request, 4 * 1024 * 1024);
json(response, 200, gameDatabase.savePartialDungeonRaceCombatLog(raceCombatLogMatch[1],
account.id, body?.combatLog, Date.now()));
return;
}
const dungeonRaceMatch = /^\/api\/dungeon-races\/(race-[A-Za-z0-9-]+)\/(ready|finish|forfeit)$/.exec(url.pathname);
if (dungeonRaceMatch && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
const [, id, action] = dungeonRaceMatch;
json(response, 200, action === "ready" ? dungeonRaceService.ready(account, id)
: action === "finish" ? dungeonRaceService.finish(account, id, await readJson(request, 4 * 1024))
: action === "finish" ? dungeonRaceService.finish(account, id, await readJson(request, 4 * 1024 * 1024))
: dungeonRaceService.leave(account, id));
return;
}