Release Healer Man 0.1.16
This commit is contained in:
1 parent
f49e54f35f
commit
b57d2b2839
89 files changed
+6055
-17
No files matched your search
@@ -8,6 +8,8 @@ import {
|
||||
normalizeManastormAdminConfig,
|
||||
stampManastormReviewMetadata,
|
||||
} from "./manastorm-config.mjs";
|
||||
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
|
||||
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
|
||||
@@ -115,7 +117,64 @@ export function openGameDatabase(options = {}) {
|
||||
) 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
|
||||
@@ -152,6 +211,23 @@ export function openGameDatabase(options = {}) {
|
||||
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) {
|
||||
@@ -563,6 +639,260 @@ export function openGameDatabase(options = {}) {
|
||||
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,
|
||||
register,
|
||||
@@ -582,6 +912,15 @@ export function openGameDatabase(options = {}) {
|
||||
updateOnlineGroupMember,
|
||||
startOnlineGroupActivity,
|
||||
clearOnlineGroupActivity,
|
||||
getArenaState,
|
||||
putArenaLoadout,
|
||||
deleteArenaLoadout,
|
||||
arenaLoadoutForQueue,
|
||||
selectedArenaLoadoutForQueue,
|
||||
getArenaRating,
|
||||
createArenaMatch,
|
||||
settleArenaMatch,
|
||||
voidArenaMatch,
|
||||
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
||||
close: () => database.close(),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user