Release Healer Man 0.1.6
This commit is contained in:
@@ -86,6 +86,34 @@ export function openGameDatabase(options = {}) {
|
||||
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);
|
||||
`);
|
||||
database.prepare(`
|
||||
INSERT OR IGNORE INTO manastorm_admin_config (
|
||||
@@ -253,6 +281,282 @@ export function openGameDatabase(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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");
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
databasePath,
|
||||
register,
|
||||
@@ -264,6 +568,14 @@ export function openGameDatabase(options = {}) {
|
||||
isGameMaster,
|
||||
getManastormConfig,
|
||||
putManastormConfig,
|
||||
onlineGroupState,
|
||||
inviteOnlineGroupMember,
|
||||
respondToOnlineGroupInvite,
|
||||
leaveOnlineGroup,
|
||||
removeOnlineGroupMember,
|
||||
updateOnlineGroupMember,
|
||||
startOnlineGroupActivity,
|
||||
clearOnlineGroupActivity,
|
||||
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
|
||||
close: () => database.close(),
|
||||
};
|
||||
|
||||
@@ -122,3 +122,60 @@ test("persists revisioned Manastorm GM configuration and rejects stale or unauth
|
||||
bosses: { "boss:incomplete": { status: "ready" } },
|
||||
}), (error) => error instanceof GameDatabaseError && error.status === 400);
|
||||
}));
|
||||
|
||||
test("creates online groups through invitations and transfers leadership when the leader leaves", () => withDatabase(async (database) => {
|
||||
const leaderAuth = await database.register("GroupLeader", "group-pass");
|
||||
const healerAuth = await database.register("GroupHealer", "healer-pass");
|
||||
const leader = database.authenticate(leaderAuth.token);
|
||||
const healer = database.authenticate(healerAuth.token);
|
||||
|
||||
const invitation = database.inviteOnlineGroupMember(leader, "grouphealer");
|
||||
assert.equal(invitation.state.group.members.length, 1);
|
||||
const incoming = database.onlineGroupState(healer.id).invitations;
|
||||
assert.equal(incoming.length, 1);
|
||||
assert.equal(incoming[0].inviterUsername, "GroupLeader");
|
||||
|
||||
const accepted = database.respondToOnlineGroupInvite(healer.id, incoming[0].id, true);
|
||||
assert.equal(accepted.group.members.length, 2);
|
||||
assert.equal(accepted.group.leaderAccountId, leader.id);
|
||||
|
||||
database.leaveOnlineGroup(leader.id);
|
||||
const transferred = database.onlineGroupState(healer.id);
|
||||
assert.equal(transferred.group.leaderAccountId, healer.id);
|
||||
assert.deepEqual(transferred.group.members.map((member) => member.username), ["GroupHealer"]);
|
||||
}));
|
||||
|
||||
test("launches players-only and AI-filled group activities only when every member is ready", () => withDatabase(async (database) => {
|
||||
const leaderAuth = await database.register("ReadyLeader", "group-pass");
|
||||
const memberAuth = await database.register("ReadyMember", "member-pass");
|
||||
const leader = database.authenticate(leaderAuth.token);
|
||||
const member = database.authenticate(memberAuth.token);
|
||||
database.inviteOnlineGroupMember(leader, member.username);
|
||||
const invite = database.onlineGroupState(member.id).invitations[0];
|
||||
database.respondToOnlineGroupInvite(member.id, invite.id, true);
|
||||
|
||||
database.updateOnlineGroupMember(leader.id, {
|
||||
id: "leader-character", name: "Leadwell", classId: "warrior", raceId: "human", gender: "male", level: 20,
|
||||
}, "tank");
|
||||
assert.throws(() => database.startOnlineGroupActivity(leader.id, {
|
||||
type: "dungeon", contentId: "wailing-caverns", fillWithAi: true, partySize: 5,
|
||||
}), (error) => error instanceof GameDatabaseError && error.code === "group_not_ready");
|
||||
|
||||
database.updateOnlineGroupMember(member.id, {
|
||||
id: "member-character", name: "Mendwell", classId: "priest", raceId: "human", gender: "female", level: 20,
|
||||
}, "healer");
|
||||
const playersOnly = database.startOnlineGroupActivity(leader.id, {
|
||||
type: "dungeon", contentId: "wailing-caverns", fillWithAi: false, partySize: 5,
|
||||
});
|
||||
assert.equal(playersOnly.group.activity.partySize, 2);
|
||||
assert.equal(playersOnly.group.activity.fillWithAi, false);
|
||||
|
||||
const filled = database.startOnlineGroupActivity(leader.id, {
|
||||
type: "manastorm", contentId: "manastorm", fillWithAi: true, partySize: 5, startingLevel: 10,
|
||||
});
|
||||
assert.equal(filled.group.activity.partySize, 5);
|
||||
assert.equal(filled.group.activity.startingLevel, 10);
|
||||
assert.throws(() => database.startOnlineGroupActivity(member.id, {
|
||||
type: "dungeon", contentId: "wailing-caverns",
|
||||
}), (error) => error instanceof GameDatabaseError && error.status === 403);
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { GameDatabaseError } from "./database.mjs";
|
||||
|
||||
const PLAYER_TIMEOUT_MS = 15_000;
|
||||
const ROOM_TIMEOUT_MS = 30 * 60_000;
|
||||
const MAX_EVENTS = 256;
|
||||
const MAX_BATCH_EVENTS = 48;
|
||||
const ALLOWED_EVENT_KINDS = new Set([
|
||||
"damage",
|
||||
"healing",
|
||||
"resurrection",
|
||||
"loot",
|
||||
"portal",
|
||||
]);
|
||||
const ALLOWED_SCHOOLS = new Set([
|
||||
"physical",
|
||||
"holy",
|
||||
"fire",
|
||||
"nature",
|
||||
"frost",
|
||||
"shadow",
|
||||
"arcane",
|
||||
]);
|
||||
|
||||
function finite(value, fallback = 0) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
function boundedString(value, maximum = 160) {
|
||||
return String(value ?? "").slice(0, maximum);
|
||||
}
|
||||
|
||||
function vector3(value) {
|
||||
if (!Array.isArray(value) || value.length < 3) return [0, 0, 0];
|
||||
return value.slice(0, 3).map((entry) => Math.max(-100_000, Math.min(100_000, finite(entry))));
|
||||
}
|
||||
|
||||
function jsonClone(value, fallback = null) {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function requireActivity(account, snapshot) {
|
||||
const group = snapshot?.group;
|
||||
const activity = group?.activity;
|
||||
const member = group?.members?.find((candidate) => candidate.accountId === account.id);
|
||||
if (!group || !activity || !member) {
|
||||
throw new GameDatabaseError("The shared activity is no longer active.", 409, "online_activity_required");
|
||||
}
|
||||
return { group, activity, member };
|
||||
}
|
||||
|
||||
function sanitizePresence(input, member, now) {
|
||||
const maximumHealth = Math.max(1, Math.min(1_000_000_000, Math.round(finite(input?.maxHealth, 1))));
|
||||
const maximumResource = Math.max(0, Math.min(1_000_000_000, Math.round(finite(input?.maxResource))));
|
||||
return {
|
||||
accountId: member.accountId,
|
||||
username: member.username,
|
||||
character: member.character,
|
||||
role: member.role,
|
||||
position: vector3(input?.position),
|
||||
yaw: Math.max(-Math.PI * 8, Math.min(Math.PI * 8, finite(input?.yaw))),
|
||||
grounded: input?.grounded !== false,
|
||||
health: Math.max(0, Math.min(maximumHealth, Math.round(finite(input?.health, maximumHealth)))),
|
||||
maxHealth: maximumHealth,
|
||||
resource: Math.max(0, Math.min(maximumResource, finite(input?.resource))),
|
||||
maxResource: maximumResource,
|
||||
resourceName: boundedString(input?.resourceName, 40),
|
||||
selectedTargetId: input?.selectedTargetId ? boundedString(input.selectedTargetId) : null,
|
||||
activeCast: input?.activeCast && typeof input.activeCast === "object"
|
||||
? jsonClone(input.activeCast)
|
||||
: null,
|
||||
animationEvent: input?.animationEvent && typeof input.animationEvent === "object"
|
||||
? jsonClone(input.animationEvent)
|
||||
: null,
|
||||
equipment: Array.isArray(input?.equipment)
|
||||
? jsonClone(input.equipment.slice(0, 24), [])
|
||||
: [],
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeEvent(input, accountId, leaderAccountId) {
|
||||
const kind = boundedString(input?.kind, 32);
|
||||
if (!ALLOWED_EVENT_KINDS.has(kind)) return null;
|
||||
const targetActorId = boundedString(input?.targetActorId);
|
||||
if (!targetActorId && kind !== "portal") return null;
|
||||
const sourceActorId = boundedString(input?.sourceActorId);
|
||||
const localPlayerActorId = `online-player:${accountId}`;
|
||||
// Non-leaders may submit only their own player actions. Enemy and AI events
|
||||
// are produced by the activity authority and cannot be forged by members.
|
||||
if (accountId !== leaderAccountId && sourceActorId !== localPlayerActorId) return null;
|
||||
const school = boundedString(input?.school, 24);
|
||||
return {
|
||||
clientEventId: boundedString(input?.clientEventId, 200),
|
||||
kind,
|
||||
sourceActorId: sourceActorId || localPlayerActorId,
|
||||
targetActorId,
|
||||
abilityId: input?.abilityId ? boundedString(input.abilityId) : null,
|
||||
school: ALLOWED_SCHOOLS.has(school) ? school : null,
|
||||
rawAmount: Math.max(0, Math.min(1_000_000_000, finite(input?.rawAmount))),
|
||||
effectiveAmount: Math.max(0, Math.min(1_000_000_000, finite(input?.effectiveAmount))),
|
||||
critical: input?.critical === true,
|
||||
occurredAt: Math.max(0, Math.trunc(finite(input?.occurredAt, Date.now()))),
|
||||
};
|
||||
}
|
||||
|
||||
export function createOnlineSessionManager() {
|
||||
const rooms = new Map();
|
||||
|
||||
function prune(now) {
|
||||
for (const [activityId, room] of rooms) {
|
||||
if (now - room.updatedAt > ROOM_TIMEOUT_MS) rooms.delete(activityId);
|
||||
}
|
||||
}
|
||||
|
||||
function roomFor(group, activity, now) {
|
||||
prune(now);
|
||||
let room = rooms.get(activity.id);
|
||||
if (!room || room.groupId !== group.id) {
|
||||
room = {
|
||||
activityId: activity.id,
|
||||
groupId: group.id,
|
||||
revision: 0,
|
||||
nextEventId: 1,
|
||||
players: new Map(),
|
||||
events: [],
|
||||
eventKeys: new Set(),
|
||||
world: null,
|
||||
updatedAt: now,
|
||||
};
|
||||
rooms.set(activity.id, room);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
function sync(account, groupSnapshot, input = {}) {
|
||||
const now = Date.now();
|
||||
const { group, activity, member } = requireActivity(account, groupSnapshot);
|
||||
const room = roomFor(group, activity, now);
|
||||
const presence = sanitizePresence(input.presence, member, now);
|
||||
room.players.set(account.id, presence);
|
||||
room.revision += 1;
|
||||
room.updatedAt = now;
|
||||
|
||||
if (input.world !== undefined) {
|
||||
if (group.leaderAccountId !== account.id) {
|
||||
throw new GameDatabaseError("Only the activity leader can publish shared world state.", 403, "activity_authority_required");
|
||||
}
|
||||
if (!input.world || typeof input.world !== "object" || Array.isArray(input.world)) {
|
||||
throw new GameDatabaseError("Shared world state must be an object.", 400, "invalid_world_state");
|
||||
}
|
||||
room.world = {
|
||||
...jsonClone(input.world, {}),
|
||||
publishedAt: now,
|
||||
publishedBy: account.id,
|
||||
};
|
||||
room.revision += 1;
|
||||
}
|
||||
|
||||
const batch = Array.isArray(input.events) ? input.events.slice(0, MAX_BATCH_EVENTS) : [];
|
||||
for (const candidate of batch) {
|
||||
const event = sanitizeEvent(candidate, account.id, group.leaderAccountId);
|
||||
if (!event?.clientEventId) continue;
|
||||
const eventKey = `${account.id}:${event.clientEventId}`;
|
||||
if (room.eventKeys.has(eventKey)) continue;
|
||||
room.eventKeys.add(eventKey);
|
||||
room.events.push({
|
||||
...event,
|
||||
id: room.nextEventId,
|
||||
sourceAccountId: account.id,
|
||||
createdAt: now,
|
||||
});
|
||||
room.nextEventId += 1;
|
||||
room.revision += 1;
|
||||
}
|
||||
if (room.events.length > MAX_EVENTS) {
|
||||
const removed = room.events.splice(0, room.events.length - MAX_EVENTS);
|
||||
for (const event of removed) room.eventKeys.delete(`${event.sourceAccountId}:${event.clientEventId}`);
|
||||
}
|
||||
|
||||
const memberIds = new Set(group.members.map((candidate) => candidate.accountId));
|
||||
for (const [accountId, player] of room.players) {
|
||||
if (!memberIds.has(accountId) || now - player.updatedAt > PLAYER_TIMEOUT_MS) {
|
||||
room.players.delete(accountId);
|
||||
}
|
||||
}
|
||||
const afterEventId = Math.max(0, Math.trunc(finite(input.afterEventId)));
|
||||
return {
|
||||
activity,
|
||||
groupId: group.id,
|
||||
leaderAccountId: group.leaderAccountId,
|
||||
localAccountId: account.id,
|
||||
authority: group.leaderAccountId === account.id,
|
||||
revision: room.revision,
|
||||
serverTime: now,
|
||||
players: [...room.players.values()].sort((left, right) => left.accountId.localeCompare(right.accountId)),
|
||||
world: room.world,
|
||||
events: room.events.filter((event) => event.id > afterEventId),
|
||||
latestEventId: room.nextEventId - 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sync,
|
||||
clear: () => rooms.clear(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { GameDatabaseError } from "./database.mjs";
|
||||
import { createOnlineSessionManager } from "./online-session.mjs";
|
||||
|
||||
function groupSnapshot() {
|
||||
return {
|
||||
invitations: [],
|
||||
group: {
|
||||
id: "group-one",
|
||||
leaderAccountId: "leader",
|
||||
members: [
|
||||
{
|
||||
accountId: "leader",
|
||||
username: "Leader",
|
||||
character: { id: "lead", name: "Bulwark", classId: "warrior", raceId: "human", gender: "male", level: 20 },
|
||||
role: "tank",
|
||||
},
|
||||
{
|
||||
accountId: "healer",
|
||||
username: "Healer",
|
||||
character: { id: "heal", name: "Mendara", classId: "priest", raceId: "human", gender: "female", level: 20 },
|
||||
role: "healer",
|
||||
},
|
||||
],
|
||||
activity: {
|
||||
id: "activity-one",
|
||||
type: "dungeon",
|
||||
contentId: "wailing-caverns",
|
||||
fillWithAi: false,
|
||||
partySize: 2,
|
||||
startingLevel: null,
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const presence = {
|
||||
position: [1, 2, 3],
|
||||
yaw: 0.5,
|
||||
health: 900,
|
||||
maxHealth: 1_000,
|
||||
resource: 400,
|
||||
maxResource: 500,
|
||||
resourceName: "Mana",
|
||||
};
|
||||
|
||||
test("synchronizes player presence, leader world state, and ordered combat events", () => {
|
||||
const manager = createOnlineSessionManager();
|
||||
const snapshot = groupSnapshot();
|
||||
const leader = manager.sync({ id: "leader" }, snapshot, {
|
||||
presence,
|
||||
world: { mobs: { boss: { health: 500, maxHealth: 500 } }, encounter: { phase: "boss" } },
|
||||
});
|
||||
assert.equal(leader.authority, true);
|
||||
assert.equal(leader.players[0].character.name, "Bulwark");
|
||||
assert.equal(leader.world.mobs.boss.health, 500);
|
||||
|
||||
const healer = manager.sync({ id: "healer" }, snapshot, {
|
||||
presence: { ...presence, position: [4, 5, 6] },
|
||||
afterEventId: 0,
|
||||
events: [{
|
||||
clientEventId: "heal:1",
|
||||
kind: "damage",
|
||||
sourceActorId: "online-player:healer",
|
||||
targetActorId: "boss",
|
||||
school: "holy",
|
||||
rawAmount: 75,
|
||||
effectiveAmount: 60,
|
||||
occurredAt: 123,
|
||||
}],
|
||||
});
|
||||
assert.equal(healer.players.length, 2);
|
||||
assert.equal(healer.events.length, 1);
|
||||
assert.equal(healer.events[0].id, 1);
|
||||
assert.equal(healer.events[0].sourceAccountId, "healer");
|
||||
|
||||
const deduplicated = manager.sync({ id: "healer" }, snapshot, {
|
||||
presence,
|
||||
afterEventId: 1,
|
||||
events: [{
|
||||
clientEventId: "heal:1",
|
||||
kind: "damage",
|
||||
sourceActorId: "online-player:healer",
|
||||
targetActorId: "boss",
|
||||
rawAmount: 75,
|
||||
effectiveAmount: 60,
|
||||
}],
|
||||
});
|
||||
assert.equal(deduplicated.events.length, 0);
|
||||
assert.equal(deduplicated.latestEventId, 1);
|
||||
});
|
||||
|
||||
test("rejects non-leader world updates and forged enemy events", () => {
|
||||
const manager = createOnlineSessionManager();
|
||||
const snapshot = groupSnapshot();
|
||||
assert.throws(() => manager.sync({ id: "healer" }, snapshot, {
|
||||
presence,
|
||||
world: { mobs: {} },
|
||||
}), (error) => error instanceof GameDatabaseError && error.code === "activity_authority_required");
|
||||
|
||||
const state = manager.sync({ id: "healer" }, snapshot, {
|
||||
presence,
|
||||
events: [{
|
||||
clientEventId: "forged",
|
||||
kind: "damage",
|
||||
sourceActorId: "boss",
|
||||
targetActorId: "online-player:leader",
|
||||
rawAmount: 1_000_000,
|
||||
}],
|
||||
});
|
||||
assert.equal(state.events.length, 0);
|
||||
});
|
||||
+58
-1
@@ -3,6 +3,7 @@ import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { GameDatabaseError, openGameDatabase } from "./database.mjs";
|
||||
import { createOnlineSessionManager } from "./online-session.mjs";
|
||||
|
||||
const MIME_TYPES = new Map([
|
||||
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
|
||||
@@ -148,6 +149,7 @@ export function createGameServer(options = {}) {
|
||||
const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024));
|
||||
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
|
||||
const gameDatabase = options.database ?? openGameDatabase(options);
|
||||
const onlineSessions = createOnlineSessionManager();
|
||||
const allowAuthRequest = createAuthLimiter();
|
||||
gameDatabase.pruneExpiredSessions();
|
||||
|
||||
@@ -209,6 +211,58 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/online-group" && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.onlineGroupState(account.id));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/online-group/invite" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, 64 * 1024);
|
||||
json(response, 201, gameDatabase.inviteOnlineGroupMember(account, body.username));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/online-group/invite/respond" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, 64 * 1024);
|
||||
json(response, 200, gameDatabase.respondToOnlineGroupInvite(account.id, body.inviteId, body.accept === true));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/online-group/leave" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.leaveOnlineGroup(account.id));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/online-group/remove" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, 64 * 1024);
|
||||
json(response, 200, gameDatabase.removeOnlineGroupMember(account.id, body.accountId));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/online-group/member" && request.method === "PUT") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, 64 * 1024);
|
||||
json(response, 200, gameDatabase.updateOnlineGroupMember(account.id, body.character, body.role));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/online-group/activity" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, 64 * 1024);
|
||||
json(response, 200, gameDatabase.startOnlineGroupActivity(account.id, body));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/online-group/activity/clear" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.clearOnlineGroupActivity(account.id));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/online-session/sync" && request.method === "POST") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, bodyLimit);
|
||||
const groupSnapshot = gameDatabase.onlineGroupState(account.id);
|
||||
json(response, 200, onlineSessions.sync(account, groupSnapshot, body));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
|
||||
const current = gameDatabase.getManastormConfig();
|
||||
json(response, 200, {
|
||||
@@ -259,7 +313,10 @@ export function createGameServer(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
server.on("close", () => gameDatabase.close());
|
||||
server.on("close", () => {
|
||||
onlineSessions.clear();
|
||||
gameDatabase.close();
|
||||
});
|
||||
return { server, database: gameDatabase, staticDir, contentDir };
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,84 @@ test("serves the game and authenticated cloud-save API", async () => {
|
||||
});
|
||||
assert.equal(denied.status, 403);
|
||||
|
||||
const memberRegistration = await fetch(`${origin}/api/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "WebTank", password: "tanking-pass" }),
|
||||
});
|
||||
const memberAuth = await memberRegistration.json();
|
||||
const invited = await fetch(`${origin}/api/online-group/invite`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({ username: "WebTank" }),
|
||||
});
|
||||
assert.equal(invited.status, 201);
|
||||
const memberGroupState = await (await fetch(`${origin}/api/online-group`, {
|
||||
headers: { Authorization: `Bearer ${memberAuth.token}` },
|
||||
})).json();
|
||||
assert.equal(memberGroupState.invitations.length, 1);
|
||||
const accepted = await fetch(`${origin}/api/online-group/invite/respond`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${memberAuth.token}` },
|
||||
body: JSON.stringify({ inviteId: memberGroupState.invitations[0].id, accept: true }),
|
||||
});
|
||||
assert.equal(accepted.status, 200);
|
||||
assert.equal((await accepted.json()).group.members.length, 2);
|
||||
|
||||
for (const [token, character, role] of [
|
||||
[auth.token, { id: "web-healer", name: "Mendara", classId: "priest", raceId: "human", gender: "female", level: 20 }, "healer"],
|
||||
[memberAuth.token, { id: "web-tank", name: "Bulwarka", classId: "warrior", raceId: "human", gender: "male", level: 20 }, "tank"],
|
||||
]) {
|
||||
const ready = await fetch(`${origin}/api/online-group/member`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ character, role }),
|
||||
});
|
||||
assert.equal(ready.status, 200);
|
||||
}
|
||||
const activityResponse = await fetch(`${origin}/api/online-group/activity`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({
|
||||
type: "dungeon",
|
||||
contentId: "wailing-caverns",
|
||||
fillWithAi: false,
|
||||
partySize: 2,
|
||||
}),
|
||||
});
|
||||
assert.equal(activityResponse.status, 200);
|
||||
const leaderSync = await fetch(`${origin}/api/online-session/sync`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` },
|
||||
body: JSON.stringify({
|
||||
afterEventId: 0,
|
||||
presence: { position: [1, 0, 2], health: 900, maxHealth: 1_000 },
|
||||
world: { mobs: { boss: { health: 500 } }, mobTransforms: {} },
|
||||
}),
|
||||
});
|
||||
assert.equal(leaderSync.status, 200);
|
||||
assert.equal((await leaderSync.json()).authority, true);
|
||||
const memberSync = await fetch(`${origin}/api/online-session/sync`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${memberAuth.token}` },
|
||||
body: JSON.stringify({
|
||||
afterEventId: 0,
|
||||
presence: { position: [3, 0, 4], health: 800, maxHealth: 1_000 },
|
||||
events: [{
|
||||
clientEventId: "web-tank-hit-1",
|
||||
kind: "damage",
|
||||
sourceActorId: `online-player:${memberAuth.account.id}`,
|
||||
targetActorId: "boss",
|
||||
rawAmount: 50,
|
||||
effectiveAmount: 45,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
assert.equal(memberSync.status, 200);
|
||||
const memberSession = await memberSync.json();
|
||||
assert.equal(memberSession.players.length, 2);
|
||||
assert.equal(memberSession.events[0].targetActorId, "boss");
|
||||
|
||||
const gmRegistration = await fetch(`${origin}/api/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
Reference in New Issue
Block a user