RunicNPC stage 5 on the site (docs runicnpc/PLAN.md, D253-D272):
- profiles gain the guard role, faction, relations (its own exceptions),
alertRadius, turrets, hurtByPlayers, hurtsPlayers and kitUse, checked in
RunicNPC's order and words, and defaulting to "players only" (D255);
- the faction table: one site-wide table, one row per pair and both ways
(D254, D268), stored in rust_npc_factions, edited on the NPC profiles page
(PUT /admin/rust/npcs/factions), pushed to every server with its
profiles and hashed with them, and a standalone server's own pairs
adopted at its first push with the site's winning (D244, D251);
- the Place NPCs step takes escort (a Steam id or a {placeholder}), an ally
(a clan from the new rust.options.clans source, or the team of a player)
and tether (the zone this event made), for a RunicNPC profile only
(D269, D270, D272);
- a placement may be held inside a zone (tether, D272);
- the bridge's RunicNPC API floor is 4.
Tests: 488 server, 66 client. routes.manifest.json regenerated against the
pinned core (one route added).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
299 lines
11 KiB
JavaScript
299 lines
11 KiB
JavaScript
// ── SQL for first-party clans ─────────────────────────────────────────────
|
|
//
|
|
// Three tables (see `schema.sql`): the clans a board carried, their members, and
|
|
// what this module knows about each server's board. Raw parameterised SQL, as
|
|
// everywhere in this module; the model decides what any of it means.
|
|
|
|
const core = require('../../core')
|
|
|
|
const CLANS = 'rust_clans'
|
|
const MEMBERS = 'rust_clan_members'
|
|
const BOARDS = 'rust_clan_boards'
|
|
const LINKS = 'rust_account_links'
|
|
const PLAYERS = 'rust_players'
|
|
const SERVERS = 'rust_servers'
|
|
|
|
// ── Boards ─────────────────────────────────────────────────────────────────
|
|
|
|
/** One server's board record, or null when it has never sent one. */
|
|
async function getBoard(serverId) {
|
|
const rows = await core.query(
|
|
`SELECT server_id AS serverId, board_t AS boardT, seen_at AS seenAt, enabled, supported,
|
|
truncated, backend, reason, umod_clans AS umodClans, clan_count AS clanCount
|
|
FROM ${BOARDS} WHERE server_id = ?`,
|
|
[serverId],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
/** Every configured server beside its board record, which may be absent. */
|
|
async function listBoards() {
|
|
return core.query(
|
|
`SELECT s.id AS serverId, s.name AS serverName, s.enabled AS serverEnabled,
|
|
b.board_t AS boardT, b.seen_at AS seenAt, b.enabled, b.supported, b.truncated,
|
|
b.backend, b.reason, b.umod_clans AS umodClans, b.clan_count AS clanCount
|
|
FROM ${SERVERS} s
|
|
LEFT JOIN ${BOARDS} b ON b.server_id = s.id
|
|
ORDER BY s.sort_order ASC, s.id ASC`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Records what a board said about itself.
|
|
*
|
|
* `seenAt` is passed only when the board's `t` ADVANCED, and is then the
|
|
* website's own now; otherwise the stored one is kept. That is the whole of the
|
|
* freshness rule (see `schema.sql`), so it is done in SQL rather than trusted to
|
|
* every caller to read-then-write.
|
|
*/
|
|
async function putBoard({ serverId, boardT, advanced, enabled, supported, truncated, backend, reason, umodClans, clanCount }) {
|
|
await core.query(
|
|
`INSERT INTO ${BOARDS}
|
|
(server_id, board_t, seen_at, enabled, supported, truncated, backend, reason, umod_clans, clan_count, updated_at)
|
|
VALUES (?, ?, ${advanced ? 'CURRENT_TIMESTAMP' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
ON DUPLICATE KEY UPDATE
|
|
board_t = VALUES(board_t),
|
|
seen_at = ${advanced ? 'CURRENT_TIMESTAMP' : 'seen_at'},
|
|
enabled = VALUES(enabled), supported = VALUES(supported), truncated = VALUES(truncated),
|
|
backend = VALUES(backend), reason = VALUES(reason), umod_clans = VALUES(umod_clans),
|
|
clan_count = VALUES(clan_count), updated_at = CURRENT_TIMESTAMP`,
|
|
[
|
|
serverId,
|
|
boardT,
|
|
enabled ? 1 : 0,
|
|
supported ? 1 : 0,
|
|
truncated ? 1 : 0,
|
|
backend || null,
|
|
reason ? String(reason).slice(0, 255) : null,
|
|
umodClans ? 1 : 0,
|
|
clanCount || 0,
|
|
],
|
|
)
|
|
}
|
|
|
|
// ── Clans ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Every clan this module holds for one server, gone or not. */
|
|
async function listClansForServer(serverId) {
|
|
return core.query(
|
|
`SELECT external_id AS externalId, clan_id AS clanId, created_ms AS createdMs, name,
|
|
member_count AS memberCount, gone_at AS goneAt
|
|
FROM ${CLANS} WHERE server_id = ?`,
|
|
[serverId],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Every member of one server's current clans, as the board last stated them,
|
|
* for diffing the next board against. The name is the BOARD's, not the player
|
|
* table's, because it is compared with the board.
|
|
*/
|
|
async function listMembersForServer(serverId) {
|
|
return core.query(
|
|
`SELECT m.external_id AS externalId, m.steam_id AS steamId, m.role_rank AS rank,
|
|
m.role_name AS role, m.name
|
|
FROM ${MEMBERS} m
|
|
JOIN ${CLANS} c ON c.external_id = m.external_id
|
|
WHERE c.server_id = ? AND c.gone_at IS NULL`,
|
|
[serverId],
|
|
)
|
|
}
|
|
|
|
async function upsertClan({ externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers }) {
|
|
await core.query(
|
|
`INSERT INTO ${CLANS}
|
|
(external_id, server_id, clan_id, created_ms, name, color, score, member_count, max_members,
|
|
first_seen, updated_at, gone_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)
|
|
ON DUPLICATE KEY UPDATE
|
|
name = VALUES(name), color = VALUES(color), score = VALUES(score),
|
|
member_count = VALUES(member_count), max_members = VALUES(max_members),
|
|
updated_at = CURRENT_TIMESTAMP, gone_at = NULL`,
|
|
[externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Replaces one clan's members.
|
|
*
|
|
* Delete then insert, not wrapped in a transaction — the same trade the presence
|
|
* board makes (`events.db.replacePresence`): a fraction of a second in which a
|
|
* roster read might come back short, against holding a lock on a table that core's
|
|
* reconciler and two public routes read.
|
|
*/
|
|
async function replaceMembers(externalId, members) {
|
|
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id = ?`, [externalId])
|
|
|
|
for (const m of members) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await core.query(
|
|
`INSERT INTO ${MEMBERS} (external_id, steam_id, name, role_rank, role_name, joined_ms)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE name = VALUES(name), role_rank = VALUES(role_rank),
|
|
role_name = VALUES(role_name), joined_ms = VALUES(joined_ms)`,
|
|
[externalId, m.steamId, m.name, m.rank, m.role, m.joinedMs],
|
|
)
|
|
}
|
|
}
|
|
|
|
/** Marks clans gone. Their members are removed with them; a gone clan has no roster. */
|
|
async function markGone(externalIds) {
|
|
if (!externalIds.length) return
|
|
const marks = externalIds.map(() => '?').join(', ')
|
|
await core.query(
|
|
`UPDATE ${CLANS} SET gone_at = CURRENT_TIMESTAMP WHERE external_id IN (${marks}) AND gone_at IS NULL`,
|
|
externalIds,
|
|
)
|
|
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id IN (${marks})`, externalIds)
|
|
}
|
|
|
|
/** One clan by its Team identity, with its server's name, or null. */
|
|
async function findClan(externalId) {
|
|
const rows = await core.query(
|
|
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
|
|
c.clan_id AS clanId, c.created_ms AS createdMs, c.name, c.color, c.score,
|
|
c.member_count AS memberCount, c.max_members AS maxMembers,
|
|
c.first_seen AS firstSeen, c.updated_at AS updatedAt, c.gone_at AS goneAt
|
|
FROM ${CLANS} c
|
|
JOIN ${SERVERS} s ON s.id = c.server_id
|
|
WHERE c.external_id = ?`,
|
|
[externalId],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
/**
|
|
* The newest clan this module holds under a game id on one server, or null.
|
|
*
|
|
* The fallback for the one event that can arrive without a creation time
|
|
* (`clan.member.added`, when the plugin could not read the clan back). Newest,
|
|
* because an id that the game has re-used belongs to the clan that re-used it.
|
|
*/
|
|
async function findByGameId(serverId, clanId) {
|
|
const rows = await core.query(
|
|
`SELECT external_id AS externalId, name
|
|
FROM ${CLANS} WHERE server_id = ? AND clan_id = ?
|
|
ORDER BY created_ms DESC LIMIT 1`,
|
|
[serverId, clanId],
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
/** Every clan still on a board, for core's `getTeams`. */
|
|
async function listActiveClans() {
|
|
return core.query(
|
|
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName, c.clan_id AS clanId,
|
|
c.name, c.color, c.score, c.member_count AS memberCount
|
|
FROM ${CLANS} c
|
|
JOIN ${SERVERS} s ON s.id = c.server_id
|
|
WHERE c.gone_at IS NULL
|
|
ORDER BY c.server_id ASC, c.score DESC, c.name ASC`,
|
|
)
|
|
}
|
|
|
|
/** One server's clans still on its board, for the public Clans tab. Best first. */
|
|
async function listPublicForServer(serverId) {
|
|
return core.query(
|
|
`SELECT external_id AS externalId, name, color, score, member_count AS memberCount,
|
|
max_members AS maxMembers
|
|
FROM ${CLANS}
|
|
WHERE server_id = ? AND gone_at IS NULL
|
|
ORDER BY score DESC, name ASC`,
|
|
[serverId],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* One clan's roster, with the website account behind each member when there is
|
|
* one and whether they are on the clan's server right now.
|
|
*
|
|
* Three joins, all of this module's own tables: the link (a Steam id to a user),
|
|
* the player table (the newest name the game has sent for them) and the presence
|
|
* board. Presence is joined on the CLAN's server — a member on another server of
|
|
* the fleet is not online here.
|
|
*/
|
|
async function listMembers(externalId) {
|
|
return core.query(
|
|
`SELECT m.steam_id AS steamId, COALESCE(p.name, m.name) AS name, m.role_rank AS rank,
|
|
m.role_name AS role, m.joined_ms AS joinedMs, l.user_id AS userId,
|
|
(pr.steam_id IS NOT NULL) AS online
|
|
FROM ${MEMBERS} m
|
|
JOIN ${CLANS} c ON c.external_id = m.external_id
|
|
LEFT JOIN ${LINKS} l ON l.steam_id = m.steam_id
|
|
LEFT JOIN ${PLAYERS} p ON p.steam_id = m.steam_id
|
|
LEFT JOIN rust_presence pr ON pr.server_id = c.server_id AND pr.steam_id = m.steam_id
|
|
WHERE m.external_id = ?
|
|
ORDER BY (m.role_rank IS NULL) ASC, m.role_rank ASC, name ASC`,
|
|
[externalId],
|
|
)
|
|
}
|
|
|
|
/** Whether a website user holds a linked Steam account that is a member of this clan. */
|
|
async function userIsMember(externalId, userId) {
|
|
const rows = await core.query(
|
|
`SELECT 1 AS yes
|
|
FROM ${MEMBERS} m
|
|
JOIN ${LINKS} l ON l.steam_id = m.steam_id
|
|
WHERE m.external_id = ? AND l.user_id = ?
|
|
LIMIT 1`,
|
|
[externalId, userId],
|
|
)
|
|
return rows.length > 0
|
|
}
|
|
|
|
/**
|
|
* Recent clan events for one server, oldest first, for re-offering their feed
|
|
* items to core until the Team they name exists (see `model/clans`).
|
|
*/
|
|
async function recentClanEvents(serverId, sinceMs) {
|
|
return core.query(
|
|
`SELECT id, kind, t, raw
|
|
FROM rust_events
|
|
WHERE server_id = ? AND kind LIKE 'clan.%' AND t >= ?
|
|
ORDER BY t ASC, id ASC
|
|
LIMIT 200`,
|
|
[serverId, sinceMs],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Notes a player's name WITHOUT touching `last_seen`.
|
|
*
|
|
* `events.db.touchPlayer` also moves `last_seen`, which is right for a frame that
|
|
* says a player was on and wrong for a clan frame: a kick is done TO somebody who
|
|
* may be offline, and a leaderboard's "last seen" would then read as a presence
|
|
* signal for a player who never connected (PLAN.md §23).
|
|
*
|
|
* A player this module has never heard of still gets a on the new row,
|
|
* because the column is NOT NULL; what matters is that an existing row's is left
|
|
* alone, and every surface that reads it is behind the presence gate anyway.
|
|
*/
|
|
async function rememberName(steamId, name) {
|
|
if (!steamId) return
|
|
await core.query(
|
|
`INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
|
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
ON DUPLICATE KEY UPDATE name = COALESCE(VALUES(name), name)`,
|
|
[steamId, name || null],
|
|
)
|
|
}
|
|
|
|
module.exports = {
|
|
getBoard,
|
|
listBoards,
|
|
putBoard,
|
|
listClansForServer,
|
|
listMembersForServer,
|
|
upsertClan,
|
|
replaceMembers,
|
|
markGone,
|
|
findClan,
|
|
findByGameId,
|
|
listActiveClans,
|
|
listPublicForServer,
|
|
listMembers,
|
|
userIsMember,
|
|
recentClanEvents,
|
|
rememberName,
|
|
}
|