Update Healer Man server
This commit is contained in:
1 parent
ab1434a3b8
commit
e284f4fc76
60 files changed
+2704
-118
No files matched your search
+4
-1
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) })) };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user