Release Healer Man 0.1.16

This commit is contained in:
phenom committed 2026-09-05 20:42:38 -04:00
1 parent f49e54f35f
commit b57d2b2839
89 files changed
+6055 -17

No files matched your search

+339
View File
@@ -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(),
};