943 lines
44 KiB
JavaScript
943 lines
44 KiB
JavaScript
import { createHash, randomBytes, randomUUID, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
|
|
import { mkdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import {
|
|
cloneDefaultManastormAdminConfig,
|
|
normalizeManastormAdminConfig,
|
|
stampManastormReviewMetadata,
|
|
} from "./manastorm-config.mjs";
|
|
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
|
|
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
|
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
|
|
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
|
|
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
|
|
|
const scrypt = promisify(scryptCallback);
|
|
|
|
export class GameDatabaseError extends Error {
|
|
constructor(message, status = 400, code = "bad_request") {
|
|
super(message);
|
|
this.name = "GameDatabaseError";
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function validateCredentials(username, password) {
|
|
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
|
|
throw new GameDatabaseError("Username must be 3-20 letters, numbers, or underscores.");
|
|
}
|
|
if (typeof password !== "string" || password.length < 8) {
|
|
throw new GameDatabaseError("Password must be at least 8 characters.");
|
|
}
|
|
if (password.length > 128) throw new GameDatabaseError("Password must be 128 characters or fewer.");
|
|
}
|
|
|
|
function tokenHash(token) {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
async function derivePassword(password, salt) {
|
|
return Buffer.from(await scrypt(password, salt, 64));
|
|
}
|
|
|
|
const GM_USERNAME_KEYS = new Set(["phenom"]);
|
|
|
|
function accountRoles(username) {
|
|
return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : [];
|
|
}
|
|
|
|
function publicAccount(row) {
|
|
return {
|
|
id: row.id,
|
|
username: row.username,
|
|
createdAt: Number(row.created_at ?? row.createdAt),
|
|
roles: accountRoles(row.username),
|
|
};
|
|
}
|
|
|
|
export function openGameDatabase(options = {}) {
|
|
const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime");
|
|
const databasePath = path.resolve(options.databasePath ?? path.join(dataDir, "game.db"));
|
|
const sessionTtlDays = Math.max(1, Number(options.sessionTtlDays ?? process.env.SESSION_TTL_DAYS ?? 30));
|
|
mkdirSync(path.dirname(databasePath), { recursive: true });
|
|
const database = new DatabaseSync(databasePath);
|
|
database.exec("PRAGMA journal_mode = WAL");
|
|
database.exec("PRAGMA foreign_keys = ON");
|
|
database.exec("PRAGMA busy_timeout = 5000");
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS accounts (
|
|
id TEXT PRIMARY KEY, username TEXT NOT NULL, username_key TEXT NOT NULL UNIQUE,
|
|
password_salt TEXT NOT NULL, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
) STRICT;
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
token_hash TEXT PRIMARY KEY,
|
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL
|
|
) STRICT;
|
|
CREATE INDEX IF NOT EXISTS sessions_account_id_idx ON sessions(account_id);
|
|
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
|
|
CREATE TABLE IF NOT EXISTS cloud_saves (
|
|
account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
|
revision INTEGER NOT NULL, data_json TEXT NOT NULL, updated_at INTEGER NOT NULL
|
|
) STRICT;
|
|
CREATE TABLE IF NOT EXISTS manastorm_admin_config (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
schema_version INTEGER NOT NULL,
|
|
revision INTEGER NOT NULL,
|
|
config_json TEXT NOT NULL,
|
|
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
|
updated_by_username TEXT,
|
|
updated_at INTEGER
|
|
) STRICT;
|
|
CREATE TABLE IF NOT EXISTS online_groups (
|
|
id TEXT PRIMARY KEY,
|
|
leader_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
activity_json TEXT,
|
|
activity_revision INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
) STRICT;
|
|
CREATE TABLE IF NOT EXISTS online_group_members (
|
|
group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE,
|
|
account_id TEXT NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
|
|
character_json TEXT,
|
|
role TEXT,
|
|
joined_at INTEGER NOT NULL,
|
|
PRIMARY KEY (group_id, account_id)
|
|
) STRICT;
|
|
CREATE INDEX IF NOT EXISTS online_group_members_group_idx ON online_group_members(group_id, joined_at);
|
|
CREATE TABLE IF NOT EXISTS online_group_invites (
|
|
id TEXT PRIMARY KEY,
|
|
group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE,
|
|
inviter_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
invitee_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
status TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
) STRICT;
|
|
CREATE INDEX IF NOT EXISTS online_group_invites_invitee_idx
|
|
ON online_group_invites(invitee_account_id, status, expires_at);
|
|
CREATE TABLE IF NOT EXISTS arena_loadouts (
|
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
character_id TEXT NOT NULL,
|
|
slot INTEGER NOT NULL CHECK (slot BETWEEN 1 AND 10),
|
|
name TEXT NOT NULL,
|
|
class_id TEXT NOT NULL,
|
|
specialization_id TEXT NOT NULL,
|
|
role TEXT NOT NULL CHECK (role IN ('healer', 'damage')),
|
|
ruleset TEXT NOT NULL CHECK (ruleset IN ('wow', 'coa', 'rom')),
|
|
level INTEGER NOT NULL CHECK (level = 80),
|
|
talents_json TEXT NOT NULL,
|
|
actions_json TEXT NOT NULL,
|
|
version INTEGER NOT NULL,
|
|
selected INTEGER NOT NULL CHECK (selected IN (0, 1)),
|
|
updated_at INTEGER NOT NULL,
|
|
PRIMARY KEY (account_id, character_id, slot)
|
|
) STRICT;
|
|
CREATE INDEX IF NOT EXISTS arena_loadouts_character_idx
|
|
ON arena_loadouts(account_id, character_id, selected);
|
|
CREATE TABLE IF NOT EXISTS arena_ratings (
|
|
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
character_id TEXT NOT NULL,
|
|
bracket TEXT NOT NULL CHECK (bracket IN ('2v2', '3v3')),
|
|
rating INTEGER NOT NULL CHECK (rating BETWEEN 0 AND 2500),
|
|
updated_at INTEGER NOT NULL,
|
|
PRIMARY KEY (account_id, character_id, bracket)
|
|
) STRICT;
|
|
CREATE TABLE IF NOT EXISTS arena_matches (
|
|
id TEXT PRIMARY KEY,
|
|
bracket TEXT NOT NULL CHECK (bracket IN ('2v2', '3v3')),
|
|
mode TEXT NOT NULL CHECK (mode IN ('rated-ai', 'ranked-pvp')),
|
|
ruleset TEXT NOT NULL CHECK (ruleset IN ('wow', 'coa', 'rom')),
|
|
stage_id TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK (status IN ('active', 'settled', 'void')),
|
|
winner_team INTEGER CHECK (winner_team IN (0, 1)),
|
|
started_at INTEGER NOT NULL,
|
|
settled_at INTEGER
|
|
) STRICT;
|
|
CREATE TABLE IF NOT EXISTS arena_match_participants (
|
|
match_id TEXT NOT NULL REFERENCES arena_matches(id) ON DELETE CASCADE,
|
|
actor_id TEXT NOT NULL,
|
|
account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
|
character_id TEXT,
|
|
team INTEGER NOT NULL CHECK (team IN (0, 1)),
|
|
role TEXT NOT NULL CHECK (role IN ('healer', 'damage')),
|
|
is_bot INTEGER NOT NULL CHECK (is_bot IN (0, 1)),
|
|
disconnected INTEGER NOT NULL DEFAULT 0 CHECK (disconnected IN (0, 1)),
|
|
rating_before INTEGER,
|
|
rating_delta INTEGER,
|
|
rating_after INTEGER,
|
|
PRIMARY KEY (match_id, actor_id),
|
|
UNIQUE (match_id, account_id, character_id)
|
|
) STRICT;
|
|
CREATE INDEX IF NOT EXISTS arena_matches_status_idx ON arena_matches(status, started_at);
|
|
`);
|
|
database.prepare(`
|
|
UPDATE arena_matches SET status = 'void', settled_at = ? WHERE status = 'active'
|
|
`).run(Date.now());
|
|
database.prepare(`
|
|
INSERT OR IGNORE INTO manastorm_admin_config (
|
|
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
|
|
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
|
|
`).run(JSON.stringify(cloneDefaultManastormAdminConfig()));
|
|
|
|
const statements = {
|
|
accountByUsername: database.prepare("SELECT * FROM accounts WHERE username_key = ?"),
|
|
insertAccount: database.prepare("INSERT INTO accounts (id, username, username_key, password_salt, password_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)"),
|
|
insertSession: database.prepare("INSERT INTO sessions (token_hash, account_id, created_at, expires_at) VALUES (?, ?, ?, ?)"),
|
|
sessionAccount: database.prepare(`
|
|
SELECT accounts.id, accounts.username, accounts.created_at, sessions.expires_at
|
|
FROM sessions JOIN accounts ON accounts.id = sessions.account_id
|
|
WHERE sessions.token_hash = ?
|
|
`),
|
|
deleteSession: database.prepare("DELETE FROM sessions WHERE token_hash = ?"),
|
|
deleteExpiredSessions: database.prepare("DELETE FROM sessions WHERE expires_at <= ?"),
|
|
cloudSave: database.prepare("SELECT revision, data_json, updated_at FROM cloud_saves WHERE account_id = ?"),
|
|
upsertCloudSave: database.prepare(`
|
|
INSERT INTO cloud_saves (account_id, revision, data_json, updated_at) VALUES (?, 1, ?, ?)
|
|
ON CONFLICT(account_id) DO UPDATE SET revision = cloud_saves.revision + 1,
|
|
data_json = excluded.data_json, updated_at = excluded.updated_at
|
|
RETURNING revision, updated_at
|
|
`),
|
|
manastormConfig: database.prepare("SELECT * FROM manastorm_admin_config WHERE id = 1"),
|
|
updateManastormConfig: database.prepare(`
|
|
UPDATE manastorm_admin_config
|
|
SET schema_version = 1,
|
|
revision = revision + 1,
|
|
config_json = ?,
|
|
updated_by_account_id = ?,
|
|
updated_by_username = ?,
|
|
updated_at = ?
|
|
WHERE id = 1 AND revision = ?
|
|
RETURNING revision, updated_at
|
|
`),
|
|
arenaLoadouts: database.prepare(`
|
|
SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ? ORDER BY slot
|
|
`),
|
|
arenaLoadout: database.prepare(`
|
|
SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ? AND slot = ?
|
|
`),
|
|
selectedArenaLoadout: database.prepare(`
|
|
SELECT * FROM arena_loadouts WHERE account_id = ? AND character_id = ?
|
|
ORDER BY selected DESC, slot ASC LIMIT 1
|
|
`),
|
|
arenaRating: database.prepare(`
|
|
SELECT rating, updated_at FROM arena_ratings WHERE account_id = ? AND character_id = ? AND bracket = ?
|
|
`),
|
|
upsertArenaRating: database.prepare(`
|
|
INSERT INTO arena_ratings (account_id, character_id, bracket, rating, updated_at) VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(account_id, character_id, bracket) DO UPDATE SET rating = excluded.rating, updated_at = excluded.updated_at
|
|
`),
|
|
};
|
|
|
|
function issueSession(accountId) {
|
|
const token = randomBytes(32).toString("base64url");
|
|
const createdAt = Date.now();
|
|
const expiresAt = createdAt + sessionTtlDays * 24 * 60 * 60 * 1000;
|
|
statements.insertSession.run(tokenHash(token), accountId, createdAt, expiresAt);
|
|
return { token, expiresAt };
|
|
}
|
|
|
|
async function register(usernameInput, password) {
|
|
const username = typeof usernameInput === "string" ? usernameInput.trim() : "";
|
|
validateCredentials(username, password);
|
|
const usernameKey = username.toLowerCase();
|
|
if (statements.accountByUsername.get(usernameKey)) {
|
|
throw new GameDatabaseError("That account already exists.", 409, "account_exists");
|
|
}
|
|
const salt = randomBytes(16);
|
|
const digest = await derivePassword(password, salt);
|
|
const account = { id: `account-${randomUUID()}`, username, createdAt: Date.now() };
|
|
try {
|
|
statements.insertAccount.run(account.id, username, usernameKey, salt.toString("base64"), digest.toString("base64"), account.createdAt);
|
|
} catch (error) {
|
|
if (String(error?.message).includes("UNIQUE constraint failed")) {
|
|
throw new GameDatabaseError("That account already exists.", 409, "account_exists");
|
|
}
|
|
throw error;
|
|
}
|
|
return { account: publicAccount(account), ...issueSession(account.id) };
|
|
}
|
|
|
|
async function login(usernameInput, password) {
|
|
const username = typeof usernameInput === "string" ? usernameInput.trim() : "";
|
|
if (!username || typeof password !== "string" || !password) {
|
|
throw new GameDatabaseError("Enter your username and password.", 400, "missing_credentials");
|
|
}
|
|
const row = statements.accountByUsername.get(username.toLowerCase());
|
|
if (!row) throw new GameDatabaseError("Incorrect username or password.", 401, "invalid_credentials");
|
|
const actual = await derivePassword(password, Buffer.from(row.password_salt, "base64"));
|
|
const expected = Buffer.from(row.password_hash, "base64");
|
|
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
|
throw new GameDatabaseError("Incorrect username or password.", 401, "invalid_credentials");
|
|
}
|
|
return { account: publicAccount(row), ...issueSession(row.id) };
|
|
}
|
|
|
|
function authenticate(token) {
|
|
if (typeof token !== "string" || token.length < 20) {
|
|
throw new GameDatabaseError("Authentication required.", 401, "authentication_required");
|
|
}
|
|
const hash = tokenHash(token);
|
|
const row = statements.sessionAccount.get(hash);
|
|
if (!row || Number(row.expires_at) <= Date.now()) {
|
|
if (row) statements.deleteSession.run(hash);
|
|
throw new GameDatabaseError("Your session has expired. Please sign in again.", 401, "session_expired");
|
|
}
|
|
return publicAccount(row);
|
|
}
|
|
|
|
function getCloudSave(accountId) {
|
|
const row = statements.cloudSave.get(accountId);
|
|
if (!row) return { revision: 0, updatedAt: null, data: { version: 1, characters: [] } };
|
|
try {
|
|
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data: JSON.parse(row.data_json) };
|
|
} catch {
|
|
throw new GameDatabaseError("The cloud save is unreadable.", 500, "cloud_save_corrupt");
|
|
}
|
|
}
|
|
|
|
function putCloudSave(accountId, data) {
|
|
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
throw new GameDatabaseError("Cloud save data must be an object.");
|
|
}
|
|
const now = Date.now();
|
|
const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now);
|
|
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
|
|
}
|
|
|
|
function isGameMaster(account) {
|
|
// Recompute authority from the server allowlist for every privileged call.
|
|
return accountRoles(account?.username).includes("gm");
|
|
}
|
|
|
|
function getManastormConfig() {
|
|
const row = statements.manastormConfig.get();
|
|
try {
|
|
return {
|
|
revision: Number(row?.revision ?? 0),
|
|
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
|
|
updatedBy: row?.updated_by_username ?? null,
|
|
config: normalizeManastormAdminConfig(JSON.parse(row?.config_json ?? "null")),
|
|
};
|
|
} catch (error) {
|
|
console.error("Healer Man Manastorm configuration is corrupt; using safe defaults", error);
|
|
return { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultManastormAdminConfig() };
|
|
}
|
|
}
|
|
|
|
function putManastormConfig(account, expectedRevision, config) {
|
|
if (!isGameMaster(account)) {
|
|
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
|
}
|
|
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
|
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
|
|
}
|
|
let normalized;
|
|
try {
|
|
normalized = normalizeManastormAdminConfig(config);
|
|
} catch (error) {
|
|
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid Manastorm configuration.", 400, "invalid_manastorm_config");
|
|
}
|
|
const now = Date.now();
|
|
const current = getManastormConfig();
|
|
normalized = stampManastormReviewMetadata(current.config, normalized, account.username, now);
|
|
const row = statements.updateManastormConfig.get(
|
|
JSON.stringify(normalized),
|
|
account.id,
|
|
account.username,
|
|
now,
|
|
expectedRevision,
|
|
);
|
|
if (!row) {
|
|
throw new GameDatabaseError("The Manastorm configuration changed. Reload and try again.", 409, "revision_conflict");
|
|
}
|
|
return {
|
|
revision: Number(row.revision),
|
|
updatedAt: Number(row.updated_at),
|
|
updatedBy: account.username,
|
|
config: normalized,
|
|
};
|
|
}
|
|
|
|
function pruneExpiredGroupInvites() {
|
|
database.prepare(`
|
|
UPDATE online_group_invites SET status = 'expired'
|
|
WHERE status = 'pending' AND expires_at <= ?
|
|
`).run(Date.now());
|
|
}
|
|
|
|
function parseStoredJson(value, fallback = null) {
|
|
if (typeof value !== "string" || !value) return fallback;
|
|
try { return JSON.parse(value); } catch { return fallback; }
|
|
}
|
|
|
|
function groupMembership(accountId) {
|
|
return database.prepare(`
|
|
SELECT online_groups.*
|
|
FROM online_group_members
|
|
JOIN online_groups ON online_groups.id = online_group_members.group_id
|
|
WHERE online_group_members.account_id = ?
|
|
`).get(accountId);
|
|
}
|
|
|
|
function publicGroup(groupId) {
|
|
const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(groupId);
|
|
if (!group) return null;
|
|
const members = database.prepare(`
|
|
SELECT accounts.id AS account_id, accounts.username,
|
|
online_group_members.character_json, online_group_members.role,
|
|
online_group_members.joined_at
|
|
FROM online_group_members
|
|
JOIN accounts ON accounts.id = online_group_members.account_id
|
|
WHERE online_group_members.group_id = ?
|
|
ORDER BY online_group_members.joined_at, accounts.username_key
|
|
`).all(groupId).map((member) => ({
|
|
accountId: member.account_id,
|
|
username: member.username,
|
|
character: parseStoredJson(member.character_json),
|
|
role: ["tank", "healer", "damage"].includes(member.role) ? member.role : null,
|
|
joinedAt: Number(member.joined_at),
|
|
}));
|
|
return {
|
|
id: group.id,
|
|
leaderAccountId: group.leader_account_id,
|
|
members,
|
|
activity: parseStoredJson(group.activity_json),
|
|
activityRevision: Number(group.activity_revision),
|
|
createdAt: Number(group.created_at),
|
|
updatedAt: Number(group.updated_at),
|
|
};
|
|
}
|
|
|
|
function onlineGroupState(accountId) {
|
|
pruneExpiredGroupInvites();
|
|
const membership = groupMembership(accountId);
|
|
const invitations = database.prepare(`
|
|
SELECT online_group_invites.id, online_group_invites.group_id,
|
|
online_group_invites.created_at, online_group_invites.expires_at,
|
|
accounts.id AS inviter_account_id, accounts.username AS inviter_username
|
|
FROM online_group_invites
|
|
JOIN accounts ON accounts.id = online_group_invites.inviter_account_id
|
|
WHERE online_group_invites.invitee_account_id = ?
|
|
AND online_group_invites.status = 'pending'
|
|
AND online_group_invites.expires_at > ?
|
|
ORDER BY online_group_invites.created_at DESC
|
|
`).all(accountId, Date.now()).map((invite) => ({
|
|
id: invite.id,
|
|
groupId: invite.group_id,
|
|
inviterAccountId: invite.inviter_account_id,
|
|
inviterUsername: invite.inviter_username,
|
|
createdAt: Number(invite.created_at),
|
|
expiresAt: Number(invite.expires_at),
|
|
}));
|
|
return { group: membership ? publicGroup(membership.id) : null, invitations };
|
|
}
|
|
|
|
function createOnlineGroup(accountId) {
|
|
const existing = groupMembership(accountId);
|
|
if (existing) return existing;
|
|
const id = `group-${randomUUID()}`;
|
|
const now = Date.now();
|
|
database.prepare(`
|
|
INSERT INTO online_groups (id, leader_account_id, activity_json, activity_revision, created_at, updated_at)
|
|
VALUES (?, ?, NULL, 0, ?, ?)
|
|
`).run(id, accountId, now, now);
|
|
database.prepare(`
|
|
INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at)
|
|
VALUES (?, ?, NULL, NULL, ?)
|
|
`).run(id, accountId, now);
|
|
return database.prepare("SELECT * FROM online_groups WHERE id = ?").get(id);
|
|
}
|
|
|
|
function inviteOnlineGroupMember(account, usernameInput) {
|
|
pruneExpiredGroupInvites();
|
|
const username = typeof usernameInput === "string" ? usernameInput.trim() : "";
|
|
if (!username) throw new GameDatabaseError("Enter the player's account name.", 400, "missing_username");
|
|
const invitee = statements.accountByUsername.get(username.toLowerCase());
|
|
if (!invitee) throw new GameDatabaseError("No online player has that account name.", 404, "player_not_found");
|
|
if (invitee.id === account.id) throw new GameDatabaseError("You cannot invite yourself.", 400, "cannot_invite_self");
|
|
if (groupMembership(invitee.id)) throw new GameDatabaseError("That player is already in a group.", 409, "player_already_grouped");
|
|
const group = createOnlineGroup(account.id);
|
|
const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count);
|
|
if (memberCount >= 5) throw new GameDatabaseError("Your group is already full.", 409, "group_full");
|
|
const existing = database.prepare(`
|
|
SELECT id FROM online_group_invites
|
|
WHERE group_id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ?
|
|
`).get(group.id, invitee.id, Date.now());
|
|
if (existing) throw new GameDatabaseError("That player already has an invitation from your group.", 409, "invite_exists");
|
|
const id = `invite-${randomUUID()}`;
|
|
const createdAt = Date.now();
|
|
const expiresAt = createdAt + 10 * 60 * 1000;
|
|
database.prepare(`
|
|
INSERT INTO online_group_invites (
|
|
id, group_id, inviter_account_id, invitee_account_id, status, created_at, expires_at
|
|
) VALUES (?, ?, ?, ?, 'pending', ?, ?)
|
|
`).run(id, group.id, account.id, invitee.id, createdAt, expiresAt);
|
|
return { id, expiresAt, state: onlineGroupState(account.id) };
|
|
}
|
|
|
|
function respondToOnlineGroupInvite(accountId, inviteId, accept) {
|
|
pruneExpiredGroupInvites();
|
|
const invite = database.prepare(`
|
|
SELECT * FROM online_group_invites
|
|
WHERE id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ?
|
|
`).get(inviteId, accountId, Date.now());
|
|
if (!invite) throw new GameDatabaseError("That group invitation is no longer available.", 404, "invite_not_found");
|
|
if (!accept) {
|
|
database.prepare("UPDATE online_group_invites SET status = 'declined' WHERE id = ?").run(invite.id);
|
|
return onlineGroupState(accountId);
|
|
}
|
|
if (groupMembership(accountId)) throw new GameDatabaseError("Leave your current group before accepting another invitation.", 409, "already_grouped");
|
|
const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(invite.group_id);
|
|
if (!group) throw new GameDatabaseError("That group no longer exists.", 404, "group_not_found");
|
|
const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count);
|
|
if (memberCount >= 5) throw new GameDatabaseError("That group is already full.", 409, "group_full");
|
|
const now = Date.now();
|
|
database.prepare(`
|
|
INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at)
|
|
VALUES (?, ?, NULL, NULL, ?)
|
|
`).run(group.id, accountId, now);
|
|
database.prepare("UPDATE online_group_invites SET status = 'accepted' WHERE id = ?").run(invite.id);
|
|
database.prepare(`
|
|
UPDATE online_group_invites SET status = 'expired'
|
|
WHERE invitee_account_id = ? AND status = 'pending'
|
|
`).run(accountId);
|
|
database.prepare(`
|
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
|
`).run(now, group.id);
|
|
return onlineGroupState(accountId);
|
|
}
|
|
|
|
function leaveOnlineGroup(accountId) {
|
|
const group = groupMembership(accountId);
|
|
if (!group) return onlineGroupState(accountId);
|
|
database.prepare("DELETE FROM online_group_members WHERE group_id = ? AND account_id = ?").run(group.id, accountId);
|
|
const remaining = database.prepare(`
|
|
SELECT account_id FROM online_group_members WHERE group_id = ? ORDER BY joined_at LIMIT 1
|
|
`).get(group.id);
|
|
if (!remaining) {
|
|
database.prepare("DELETE FROM online_groups WHERE id = ?").run(group.id);
|
|
} else {
|
|
database.prepare(`
|
|
UPDATE online_groups
|
|
SET leader_account_id = CASE WHEN leader_account_id = ? THEN ? ELSE leader_account_id END,
|
|
activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(accountId, remaining.account_id, Date.now(), group.id);
|
|
}
|
|
return onlineGroupState(accountId);
|
|
}
|
|
|
|
function removeOnlineGroupMember(accountId, targetAccountId) {
|
|
const group = groupMembership(accountId);
|
|
if (!group || group.leader_account_id !== accountId) {
|
|
throw new GameDatabaseError("Only the group leader can remove players.", 403, "leader_required");
|
|
}
|
|
if (targetAccountId === accountId) return leaveOnlineGroup(accountId);
|
|
const removed = database.prepare(`
|
|
DELETE FROM online_group_members WHERE group_id = ? AND account_id = ?
|
|
`).run(group.id, targetAccountId);
|
|
if (!removed.changes) throw new GameDatabaseError("That player is not in your group.", 404, "member_not_found");
|
|
database.prepare(`
|
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
|
`).run(Date.now(), group.id);
|
|
return onlineGroupState(accountId);
|
|
}
|
|
|
|
function normalizeGroupCharacter(character) {
|
|
if (!character || typeof character !== "object" || Array.isArray(character)) return null;
|
|
const id = String(character.id ?? "").slice(0, 100);
|
|
const name = String(character.name ?? "").trim().slice(0, 30);
|
|
const classId = String(character.classId ?? "").slice(0, 50);
|
|
if (!id || !name || !classId) throw new GameDatabaseError("Select a valid character for the group.", 400, "invalid_group_character");
|
|
let appearance = {};
|
|
if (character.appearance && typeof character.appearance === "object" && !Array.isArray(character.appearance)) {
|
|
try {
|
|
const serialized = JSON.stringify(character.appearance);
|
|
if (serialized.length <= 8 * 1024) appearance = JSON.parse(serialized);
|
|
} catch {
|
|
appearance = {};
|
|
}
|
|
}
|
|
return {
|
|
id,
|
|
name,
|
|
classId,
|
|
specializationId: typeof character.specializationId === "string"
|
|
? character.specializationId.slice(0, 100)
|
|
: null,
|
|
categoryId: String(character.categoryId ?? "wow").slice(0, 20),
|
|
raceId: String(character.raceId ?? "human").slice(0, 50),
|
|
gender: character.gender === "female" ? "female" : "male",
|
|
level: Math.max(1, Math.min(1000, Math.trunc(Number(character.level) || 1))),
|
|
appearance,
|
|
};
|
|
}
|
|
|
|
function updateOnlineGroupMember(accountId, character, role) {
|
|
const group = groupMembership(accountId);
|
|
if (!group) throw new GameDatabaseError("Join or create a group first.", 409, "group_required");
|
|
if (!["tank", "healer", "damage"].includes(role)) {
|
|
throw new GameDatabaseError("Choose a valid party role.", 400, "invalid_party_role");
|
|
}
|
|
database.prepare(`
|
|
UPDATE online_group_members SET character_json = ?, role = ?
|
|
WHERE group_id = ? AND account_id = ?
|
|
`).run(JSON.stringify(normalizeGroupCharacter(character)), role, group.id, accountId);
|
|
database.prepare("UPDATE online_groups SET updated_at = ? WHERE id = ?").run(Date.now(), group.id);
|
|
return onlineGroupState(accountId);
|
|
}
|
|
|
|
function startOnlineGroupActivity(accountId, input) {
|
|
const group = groupMembership(accountId);
|
|
if (!group || group.leader_account_id !== accountId) {
|
|
throw new GameDatabaseError("Only the group leader can start an activity.", 403, "leader_required");
|
|
}
|
|
const type = input?.type;
|
|
if (type !== "dungeon" && type !== "manastorm") {
|
|
throw new GameDatabaseError("Choose a Dungeon or Manastorm.", 400, "invalid_activity_type");
|
|
}
|
|
const contentId = String(input?.contentId ?? "").trim().slice(0, 120);
|
|
if (!contentId) throw new GameDatabaseError("Choose content before starting.", 400, "missing_content");
|
|
if (type === "dungeon" && input?.bossDifficulty !== undefined
|
|
&& (!Number.isInteger(input.bossDifficulty) || input.bossDifficulty < 1 || input.bossDifficulty > 10)) {
|
|
throw new GameDatabaseError("Choose a boss difficulty from 1 to 10.", 400, "invalid_boss_difficulty");
|
|
}
|
|
if (type === "dungeon" && input?.difficultyId !== undefined
|
|
&& !dungeonLeaderboardCatalog.dungeons.find((dungeon) => dungeon.id === contentId)
|
|
?.difficulties.some((difficulty) => difficulty.id === input.difficultyId)) {
|
|
throw new GameDatabaseError("Choose a supported dungeon difficulty.", 400, "invalid_dungeon_difficulty");
|
|
}
|
|
const members = database.prepare(`
|
|
SELECT account_id, character_json, role FROM online_group_members WHERE group_id = ? ORDER BY joined_at
|
|
`).all(group.id);
|
|
if (members.some((member) => !member.character_json || !["tank", "healer", "damage"].includes(member.role))) {
|
|
throw new GameDatabaseError("Every player must select a character and party role before the group can start.", 409, "group_not_ready");
|
|
}
|
|
const fillWithAi = input?.fillWithAi !== false;
|
|
const requestedSize = Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5)));
|
|
const partySize = fillWithAi ? Math.max(members.length, requestedSize) : members.length;
|
|
const activity = {
|
|
id: `activity-${randomUUID()}`,
|
|
type,
|
|
contentId,
|
|
...(type === "dungeon" ? { difficultyId: input?.difficultyId, bossDifficulty: input?.bossDifficulty ?? 1 } : {}),
|
|
fillWithAi,
|
|
partySize,
|
|
startingLevel: type === "manastorm"
|
|
? Math.max(1, Math.trunc(Number(input?.startingLevel) || 1))
|
|
: null,
|
|
startedAt: Date.now(),
|
|
};
|
|
database.prepare(`
|
|
UPDATE online_groups
|
|
SET activity_json = ?, activity_revision = activity_revision + 1, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(JSON.stringify(activity), activity.startedAt, group.id);
|
|
return onlineGroupState(accountId);
|
|
}
|
|
|
|
function clearOnlineGroupActivity(accountId) {
|
|
const group = groupMembership(accountId);
|
|
if (!group || group.leader_account_id !== accountId) {
|
|
throw new GameDatabaseError("Only the group leader can reset the activity.", 403, "leader_required");
|
|
}
|
|
database.prepare(`
|
|
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
|
|
`).run(Date.now(), group.id);
|
|
return onlineGroupState(accountId);
|
|
}
|
|
|
|
function arenaCharacter(accountId, characterId) {
|
|
const save = getCloudSave(accountId);
|
|
const characters = Array.isArray(save.data?.characters) ? save.data.characters : [];
|
|
const character = characters.find((entry) => String(entry?.id ?? "") === characterId);
|
|
if (!character) throw new GameDatabaseError("That character is not in your roster.", 404, "arena_character_not_found");
|
|
return character;
|
|
}
|
|
|
|
function parseArenaLoadout(row) {
|
|
return {
|
|
slot: Number(row.slot),
|
|
name: row.name,
|
|
characterId: row.character_id,
|
|
classId: row.class_id,
|
|
specializationId: row.specialization_id,
|
|
role: row.role,
|
|
ruleset: row.ruleset,
|
|
level: 80,
|
|
talents: parseStoredJson(row.talents_json, {}),
|
|
actions: parseStoredJson(row.actions_json, {}),
|
|
version: Number(row.version),
|
|
selected: Boolean(row.selected),
|
|
updatedAt: Number(row.updated_at),
|
|
};
|
|
}
|
|
|
|
function validateArenaRecord(value, name, maxEntries, maxLength) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new GameDatabaseError(`${name} must be an object.`, 400, `invalid_arena_${name.toLowerCase()}`);
|
|
}
|
|
const entries = Object.entries(value);
|
|
if (entries.length > maxEntries) {
|
|
throw new GameDatabaseError(`${name} has too many entries.`, 400, `invalid_arena_${name.toLowerCase()}`);
|
|
}
|
|
const json = JSON.stringify(value);
|
|
if (json.length > maxLength) {
|
|
throw new GameDatabaseError(`${name} is too large.`, 400, `invalid_arena_${name.toLowerCase()}`);
|
|
}
|
|
return json;
|
|
}
|
|
|
|
function putArenaLoadout(accountId, characterIdInput, slotInput, input) {
|
|
const characterId = String(characterIdInput ?? "").trim().slice(0, 100);
|
|
const slot = Math.trunc(Number(slotInput));
|
|
if (!characterId || !Number.isInteger(slot) || slot < 1 || slot > 10) {
|
|
throw new GameDatabaseError("Choose loadout slot 1 through 10.", 400, "invalid_arena_loadout_slot");
|
|
}
|
|
const character = arenaCharacter(accountId, characterId);
|
|
const classId = String(input?.classId ?? character.classId ?? "").trim().toLowerCase().slice(0, 50);
|
|
if (!classId || classId !== String(character.classId ?? "").trim().toLowerCase()) {
|
|
throw new GameDatabaseError("The loadout specialization must belong to this character's class.", 400, "arena_class_mismatch");
|
|
}
|
|
const ruleset = String(input?.ruleset ?? character.categoryId ?? "wow").trim().toLowerCase();
|
|
if (!["wow", "coa", "rom"].includes(ruleset)) {
|
|
throw new GameDatabaseError("Choose a valid arena ruleset.", 400, "invalid_arena_ruleset");
|
|
}
|
|
const specializationId = String(input?.specializationId ?? "").trim().toLowerCase().slice(0, 100);
|
|
const role = arenaRoleForSpecialization(classId, specializationId, ruleset);
|
|
if (role === "tank") throw new GameDatabaseError("Tank specializations cannot enter rated arenas.", 400, "arena_tank_forbidden");
|
|
if (!role || !["healer", "damage"].includes(role)) {
|
|
throw new GameDatabaseError("Choose a valid healer or damage specialization.", 400, "invalid_arena_specialization");
|
|
}
|
|
if (input?.role && input.role !== role) {
|
|
throw new GameDatabaseError("The selected role does not match that specialization.", 400, "arena_role_mismatch");
|
|
}
|
|
const name = String(input?.name ?? `Loadout ${slot}`).trim().slice(0, 32);
|
|
if (!name) throw new GameDatabaseError("Name this PvP loadout.", 400, "invalid_arena_loadout_name");
|
|
const talentsJson = validateArenaRecord(input?.talents ?? {}, "Talents", 100, 16 * 1024);
|
|
const actionsJson = validateArenaRecord(input?.actions ?? {}, "Actions", 120, 24 * 1024);
|
|
const previous = statements.arenaLoadout.get(accountId, characterId, slot);
|
|
const version = Number(previous?.version ?? 0) + 1;
|
|
const selected = input?.selected === true || !statements.arenaLoadouts.all(accountId, characterId).length;
|
|
const now = Date.now();
|
|
database.exec("BEGIN IMMEDIATE");
|
|
try {
|
|
if (selected) database.prepare("UPDATE arena_loadouts SET selected = 0 WHERE account_id = ? AND character_id = ?").run(accountId, characterId);
|
|
database.prepare(`
|
|
INSERT INTO arena_loadouts (
|
|
account_id, character_id, slot, name, class_id, specialization_id, role, ruleset,
|
|
level, talents_json, actions_json, version, selected, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 80, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(account_id, character_id, slot) DO UPDATE SET
|
|
name = excluded.name, class_id = excluded.class_id,
|
|
specialization_id = excluded.specialization_id, role = excluded.role,
|
|
ruleset = excluded.ruleset, level = 80, talents_json = excluded.talents_json,
|
|
actions_json = excluded.actions_json, version = excluded.version,
|
|
selected = excluded.selected, updated_at = excluded.updated_at
|
|
`).run(
|
|
accountId, characterId, slot, name, classId, specializationId, role, ruleset,
|
|
talentsJson, actionsJson, version, selected ? 1 : 0, now,
|
|
);
|
|
database.exec("COMMIT");
|
|
} catch (error) {
|
|
database.exec("ROLLBACK");
|
|
throw error;
|
|
}
|
|
return parseArenaLoadout(statements.arenaLoadout.get(accountId, characterId, slot));
|
|
}
|
|
|
|
function deleteArenaLoadout(accountId, characterIdInput, slotInput) {
|
|
const characterId = String(characterIdInput ?? "").trim().slice(0, 100);
|
|
const slot = Math.trunc(Number(slotInput));
|
|
arenaCharacter(accountId, characterId);
|
|
const previous = statements.arenaLoadout.get(accountId, characterId, slot);
|
|
if (!previous) throw new GameDatabaseError("That PvP loadout does not exist.", 404, "arena_loadout_not_found");
|
|
database.exec("BEGIN IMMEDIATE");
|
|
try {
|
|
database.prepare("DELETE FROM arena_loadouts WHERE account_id = ? AND character_id = ? AND slot = ?").run(accountId, characterId, slot);
|
|
if (previous.selected) {
|
|
database.prepare(`
|
|
UPDATE arena_loadouts SET selected = 1
|
|
WHERE account_id = ? AND character_id = ? AND slot = (
|
|
SELECT MIN(slot) FROM arena_loadouts WHERE account_id = ? AND character_id = ?
|
|
)
|
|
`).run(accountId, characterId, accountId, characterId);
|
|
}
|
|
database.exec("COMMIT");
|
|
} catch (error) {
|
|
database.exec("ROLLBACK");
|
|
throw error;
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
function getArenaRating(accountId, characterId, bracket) {
|
|
if (!["2v2", "3v3"].includes(bracket)) throw new GameDatabaseError("Choose 2v2 or 3v3.", 400, "invalid_arena_bracket");
|
|
const row = statements.arenaRating.get(accountId, characterId, bracket);
|
|
const rating = clampArenaRating(row?.rating ?? 0);
|
|
return { bracket, rating, title: arenaRatingBand(rating).title, updatedAt: row ? Number(row.updated_at) : null };
|
|
}
|
|
|
|
function getArenaState(accountId, characterIdInput = null) {
|
|
const cloud = getCloudSave(accountId);
|
|
const roster = Array.isArray(cloud.data?.characters) ? cloud.data.characters : [];
|
|
const requestedId = String(characterIdInput ?? "").trim();
|
|
const character = requestedId ? arenaCharacter(accountId, requestedId) : roster[0] ?? null;
|
|
if (!character) return { characterId: null, loadouts: [], ratings: { "2v2": null, "3v3": null } };
|
|
const characterId = String(character.id);
|
|
return {
|
|
characterId,
|
|
loadouts: statements.arenaLoadouts.all(accountId, characterId).map(parseArenaLoadout),
|
|
ratings: {
|
|
"2v2": getArenaRating(accountId, characterId, "2v2"),
|
|
"3v3": getArenaRating(accountId, characterId, "3v3"),
|
|
},
|
|
};
|
|
}
|
|
|
|
function arenaLoadoutForQueue(accountId, characterId, slot) {
|
|
arenaCharacter(accountId, characterId);
|
|
const row = statements.arenaLoadout.get(accountId, characterId, Math.trunc(Number(slot)));
|
|
if (!row) throw new GameDatabaseError("Create that PvP loadout before queueing.", 409, "arena_loadout_required");
|
|
return parseArenaLoadout(row);
|
|
}
|
|
|
|
function selectedArenaLoadoutForQueue(accountId, characterId) {
|
|
arenaCharacter(accountId, characterId);
|
|
const row = statements.selectedArenaLoadout.get(accountId, characterId);
|
|
if (!row) throw new GameDatabaseError("Create and select a PvP loadout before queueing.", 409, "arena_loadout_required");
|
|
return parseArenaLoadout(row);
|
|
}
|
|
|
|
function createArenaMatch(match) {
|
|
database.exec("BEGIN IMMEDIATE");
|
|
try {
|
|
database.prepare(`
|
|
INSERT INTO arena_matches (id, bracket, mode, ruleset, stage_id, status, winner_team, started_at, settled_at)
|
|
VALUES (?, ?, ?, ?, ?, 'active', NULL, ?, NULL)
|
|
`).run(match.id, match.bracket, match.mode, match.ruleset, match.stageId, match.startedAt);
|
|
const insertParticipant = database.prepare(`
|
|
INSERT INTO arena_match_participants (
|
|
match_id, actor_id, account_id, character_id, team, role, is_bot, disconnected,
|
|
rating_before, rating_delta, rating_after
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
|
|
`);
|
|
for (const participant of match.participants) {
|
|
const before = participant.isBot
|
|
? clampArenaRating(participant.rating)
|
|
: getArenaRating(participant.accountId, participant.characterId, match.bracket).rating;
|
|
insertParticipant.run(
|
|
match.id, participant.actorId, participant.accountId ?? null, participant.characterId ?? null,
|
|
participant.team, participant.role, participant.isBot ? 1 : 0, participant.disconnected ? 1 : 0, before,
|
|
);
|
|
}
|
|
database.exec("COMMIT");
|
|
} catch (error) {
|
|
database.exec("ROLLBACK");
|
|
throw error;
|
|
}
|
|
return match;
|
|
}
|
|
|
|
function settleArenaMatch(matchId, winnerTeam) {
|
|
database.exec("BEGIN IMMEDIATE");
|
|
try {
|
|
const match = database.prepare("SELECT * FROM arena_matches WHERE id = ?").get(matchId);
|
|
if (!match) throw new GameDatabaseError("Arena match not found.", 404, "arena_match_not_found");
|
|
const rows = database.prepare("SELECT * FROM arena_match_participants WHERE match_id = ? ORDER BY actor_id").all(matchId);
|
|
if (match.status === "settled") {
|
|
database.exec("COMMIT");
|
|
return {
|
|
matchId,
|
|
winnerTeam: match.winner_team == null ? null : Number(match.winner_team),
|
|
draw: match.winner_team == null,
|
|
ratingUpdates: rows.filter((row) => !row.is_bot).map((row) => ({
|
|
characterId: row.character_id, bracket: match.bracket, before: Number(row.rating_before),
|
|
delta: Number(row.rating_delta), after: Number(row.rating_after), title: arenaRatingBand(row.rating_after).title,
|
|
})),
|
|
};
|
|
}
|
|
if (match.status === "void") throw new GameDatabaseError("That arena match was voided.", 409, "arena_match_void");
|
|
const normalizedWinner = winnerTeam === 0 || winnerTeam === 1 ? winnerTeam : null;
|
|
const teamRatings = [0, 1].map((team) => {
|
|
const teamRows = rows.filter((row) => Number(row.team) === team);
|
|
return Math.round(teamRows.reduce((sum, row) => sum + Number(row.rating_before ?? 0), 0) / Math.max(1, teamRows.length));
|
|
});
|
|
const updates = [];
|
|
for (const row of rows) {
|
|
if (row.is_bot || !row.account_id || !row.character_id) continue;
|
|
const team = Number(row.team);
|
|
const score = normalizedWinner == null ? 0.5 : normalizedWinner === team ? 1 : 0;
|
|
const update = calculateArenaRating({
|
|
rating: Number(row.rating_before), opponentRating: teamRatings[team === 0 ? 1 : 0], score,
|
|
});
|
|
database.prepare(`
|
|
UPDATE arena_match_participants SET rating_delta = ?, rating_after = ?
|
|
WHERE match_id = ? AND actor_id = ? AND rating_after IS NULL
|
|
`).run(update.delta, update.after, matchId, row.actor_id);
|
|
statements.upsertArenaRating.run(row.account_id, row.character_id, match.bracket, update.after, Date.now());
|
|
updates.push({
|
|
characterId: row.character_id, bracket: match.bracket, before: update.before,
|
|
delta: update.delta, after: update.after, title: arenaRatingBand(update.after).title,
|
|
});
|
|
}
|
|
const settledAt = Date.now();
|
|
database.prepare(`
|
|
UPDATE arena_matches SET status = 'settled', winner_team = ?, settled_at = ? WHERE id = ? AND status = 'active'
|
|
`).run(normalizedWinner, settledAt, matchId);
|
|
database.exec("COMMIT");
|
|
return { matchId, winnerTeam: normalizedWinner, draw: normalizedWinner == null, ratingUpdates: updates };
|
|
} catch (error) {
|
|
database.exec("ROLLBACK");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function voidArenaMatch(matchId) {
|
|
const settledAt = Date.now();
|
|
const result = database.prepare(`
|
|
UPDATE arena_matches SET status = 'void', settled_at = ? WHERE id = ? AND status = 'active'
|
|
`).run(settledAt, matchId);
|
|
return { ok: true, changed: Number(result.changes) > 0 };
|
|
}
|
|
|
|
return {
|
|
databasePath,
|
|
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
|
|
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
|
|
register,
|
|
login,
|
|
authenticate,
|
|
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
|
|
getCloudSave,
|
|
putCloudSave,
|
|
isGameMaster,
|
|
getManastormConfig,
|
|
putManastormConfig,
|
|
onlineGroupState,
|
|
inviteOnlineGroupMember,
|
|
respondToOnlineGroupInvite,
|
|
leaveOnlineGroup,
|
|
removeOnlineGroupMember,
|
|
updateOnlineGroupMember,
|
|
startOnlineGroupActivity,
|
|
clearOnlineGroupActivity,
|
|
getArenaState,
|
|
putArenaLoadout,
|
|
deleteArenaLoadout,
|
|
arenaLoadoutForQueue,
|
|
selectedArenaLoadoutForQueue,
|
|
getArenaRating,
|
|
createArenaMatch,
|
|
settleArenaMatch,
|
|
voidArenaMatch,
|
|
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
|
close: () => database.close(),
|
|
};
|
|
}
|