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