Files
Module-Rust/server/model/visibility/visibility.model.js
wtclaude cc185db26b feat(rust): the rewards — tally, kit reward, chat and the news leg (phase 13b, protocol 10)
Four event verbs and the announce leg, per PLAN.md §29:

- rust.participation.open / .collect: the plugin counts who takes part
  (seconds, kills or both, in a zone this run opened or the whole server)
  and collect files them as the run's participants, keyed by Steam id.
- rust.kit.entitle: the five recipient modes (D101), rows in the new
  rust_perm_run_grants (D84) unioned into the permission push, one extra
  use of the kit per reward as site-held credits on perm.sync (D103),
  and the rust.kit.entitled notice deferred from phase 10 (D64).
- rust.announce: one server or every server (D105).
- rust.chat announce leg, speaking only on servers whose new news switch
  is on (D104) - a card on Admin -> Rust visibility (D106).

Budgets rust.grants and rust.announcements; the kit source and four
fixed-choice sources (core has no enum param type). rust_perm_run_grants
carries core's idempotency key so a revert of a lost answer can find its
rows. Protocol 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 07:00:44 -05:00

287 lines
11 KiB
JavaScript

// ── Who may see who is online ─────────────────────────────────────────────
//
// The org lead's rule, settled 2026-09-22: **nothing tells who is online by
// default.** It is always the lowest blast radius — staff — unless an operator
// deliberately widens it, and a COUNT of players is fine where a list of names
// is not.
//
// "Who is online" is wider than the Online tab. Every frame that says a named
// player was on the server at a given moment says it: a connect, a respawn, a
// death, a chat line, a gather tally (`catalogue.PRESENCE_KINDS`), and a
// leaderboard row's `lastSeen`, which a tally refreshes every minute while
// somebody plays. All of them sit behind this one setting.
//
// ── The audiences ─────────────────────────────────────────────────────────
//
// staff an admin or a moderator — the two roles every Team surface in
// core also means by "staff"
// signed_in any active website account
// public anybody, signed in or not
//
// Ordered, each rung implying the ones below it. The names line up with phase
// 14's map-layer switches (public / players / admin) so that one layer can take
// this over rather than sit beside it.
//
// ── Two fallbacks, deliberately asymmetric ────────────────────────────────
//
// An unrecognised VIEWER reads as the bottom rung and an unrecognised
// REQUIREMENT reads as the top one, so a value nobody expected always loses.
// One shared fallback cannot do that: whichever way it points, it fails open on
// one side. module-uo's shard visibility learned this the hard way; the rule is
// copied here rather than rediscovered.
const core = require('../../core')
const db = require('./visibility.db')
const log = core.logger('visibility')
const AUDIENCES = Object.freeze(['public', 'signed_in', 'staff'])
const RANK = new Map(AUDIENCES.map((a, i) => [a, i]))
/** The narrowest rung, and the default wherever nothing has been chosen. */
const DEFAULT_PRESENCE = 'staff'
/** The `rust_settings` key the fleet default lives under. */
const PRESENCE_KEY = 'presence.audience'
const isAudience = (value) => RANK.has(value)
// ── Who may see a clan's roster (phase 9, D48) ────────────────────────────
//
// The same rule applied to a roster: a roster says who is in a clan and, inside
// its audience, which of them is on. So it defaults to the clan's OWN members
// plus staff, and an operator widens it deliberately.
//
// members staff, and a website account linked to one of the clan's members
// signed_in any active website account
// public anybody
//
// One fleet-wide setting (D48), deliberately without a per-server override: the
// presence override exists because a PvE server may publish a roll call a PvP one
// must not, and a roster is the same answer on every server of the fleet.
//
// **Widening it widens online status too.** Core's `projectRoster` can withhold a
// roster's rows but not its fields, so there is no rung that shows who is in a
// clan and hides which of them is on. The admin page says so beside the switch.
const CLAN_AUDIENCES = Object.freeze(['public', 'signed_in', 'members'])
/** The narrowest rung, and the default until an operator chooses. */
const DEFAULT_CLAN_ROSTER = 'members'
/** The `rust_settings` key the roster audience lives under. */
const CLAN_ROSTER_KEY = 'clans.roster.audience'
const isClanAudience = (value) => CLAN_AUDIENCES.includes(value)
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('staff')
/** Does a viewer at `viewer` satisfy a requirement of `required`? */
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
/**
* The viewer's rung, re-read from the database.
*
* `getUserFromRequest` decodes a token and nothing more — the role in it is the
* role the account had when it signed in. For a gate on who may see who is
* online, that is not good enough: a moderator demoted this morning would keep
* the roll call until their token expired, and a banned account would keep
* reading it too. So the token only says WHO; the row says what they are now.
*
* Any failure resolves to `public` — the bottom rung — because an unanswerable
* question about somebody's standing must grant nothing.
*/
async function viewerLevel(req) {
try {
const claimed = req.user || core.auth.getUserFromRequest(req)
if (!claimed || claimed.id == null) return 'public'
const user = await core.users.getById(claimed.id)
if (!user) return 'public'
if (user.status && user.status !== 'active') return 'public'
if (user.role === 'admin' || user.role === 'moderator') return 'staff'
return 'signed_in'
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return 'public'
}
}
/** A stored value as an audience, narrowing anything this build does not recognise. */
function normalise(value) {
return isAudience(value) ? value : DEFAULT_PRESENCE
}
/** The fleet default. */
async function fleetPresence() {
const stored = await db.getSetting(PRESENCE_KEY)
return stored == null ? DEFAULT_PRESENCE : normalise(stored)
}
/**
* The audience that applies to one server: its override if it has one, the
* fleet default otherwise.
*
* A server that does not exist gets the fleet default, which is the right answer
* for the routes that call this: they answer an empty list for an unknown id,
* and an empty list is empty at every rung.
*/
async function presenceFor(serverId) {
const override = await db.getServerPresence(serverId)
if (override != null) return normalise(override)
return fleetPresence()
}
/**
* Everything a public route needs in one call: may this viewer see who is on
* this server?
*
* Throws nothing. A setting that cannot be read resolves to "no" — the routes
* that ask would otherwise have to choose between a 500 and publishing names.
*/
async function canSeePresence(req, serverId) {
try {
const [level, required] = await Promise.all([viewerLevel(req), presenceFor(serverId)])
return { visible: meets(level, required), level, required }
} catch (err) {
log.warn('could not resolve presence visibility; withholding it', { server: serverId, error: err.message })
return { visible: false, level: 'public', required: DEFAULT_PRESENCE }
}
}
/**
* The clan roster audience. An unrecognised stored word narrows to `members`,
* and a read that fails throws — every caller answers "no" on a throw, which is
* the direction a roster must fail in.
*/
async function clanRosterAudience() {
const stored = await db.getSetting(CLAN_ROSTER_KEY)
return isClanAudience(stored) ? stored : DEFAULT_CLAN_ROSTER
}
/** The admin screen's read: the fleet default and every server beside it. */
async function describe() {
const [fleet, servers, clanRoster] = await Promise.all([
fleetPresence(),
db.listServerPresence(),
clanRosterAudience(),
])
return {
audiences: [...AUDIENCES],
clans: { audiences: [...CLAN_AUDIENCES], roster: clanRoster },
presence: {
fleet,
servers: servers.map((s) => {
const override = s.presence == null ? null : normalise(s.presence)
return {
id: s.id,
name: s.name,
enabled: Boolean(s.enabled),
override,
effective: override || fleet,
}
}),
},
// D104/D106: whether a published news post is said in each server's chat.
// On this page because it is the one that lists every server with a setting
// of its own, and it answers the same kind of question — what a server shows.
news: {
servers: servers.map((s) => ({ id: s.id, name: s.name, enabled: Boolean(s.enabled), on: Boolean(Number(s.announceNews)) })),
},
}
}
/**
* The admin screen's write.
*
* `fleet` is optional; `servers` maps an id to an audience, or to `null` to
* clear its override. Validated whole before anything is written, so a request
* naming one unknown server changes nothing rather than half of what it asked.
*
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
* sentence the page can show.
*/
async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
if (fleet !== undefined && !isAudience(fleet)) {
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
}
if (clanRoster !== undefined && !isClanAudience(clanRoster)) {
return {
ok: false,
status: 400,
message: `"${clanRoster}" is not a clan roster audience. Choose one of: ${CLAN_AUDIENCES.join(', ')}.`,
}
}
const changes = Object.entries(servers || {})
for (const [id, value] of changes) {
if (value !== null && !isAudience(value)) {
return { ok: false, status: 400, message: `"${value}" is not an audience for server ${id}.` }
}
// eslint-disable-next-line no-await-in-loop
if ((await db.getServerPresence(id)) === undefined) {
return { ok: false, status: 404, message: `There is no server called ${id}.` }
}
}
const newsChanges = Object.entries(news || {})
for (const [id, value] of newsChanges) {
if (typeof value !== 'boolean') {
return { ok: false, status: 400, message: `News in game chat is on or off for server ${id}, not "${value}".` }
}
// eslint-disable-next-line no-await-in-loop
if ((await db.getServerPresence(id)) === undefined) {
return { ok: false, status: 404, message: `There is no server called ${id}.` }
}
}
const userId = actor && actor.id != null ? actor.id : null
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
if (clanRoster !== undefined) await db.setSetting(CLAN_ROSTER_KEY, clanRoster, userId)
for (const [id, value] of changes) {
// eslint-disable-next-line no-await-in-loop
await db.setServerPresence(id, value)
}
for (const [id, on] of newsChanges) {
// eslint-disable-next-line no-await-in-loop
await db.setServerNews(id, on)
}
// What was written, for the controller's audit row. Recorded there rather than
// here because the activity log takes the REQUEST (who, from where), and a
// model that took a request would be a model that could only be called by one.
return {
ok: true,
changed: {
...(fleet !== undefined ? { fleet } : {}),
...(clanRoster !== undefined ? { clanRoster } : {}),
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
...(newsChanges.length ? { news: Object.fromEntries(newsChanges) } : {}),
},
}
}
module.exports = {
AUDIENCES,
DEFAULT_PRESENCE,
PRESENCE_KEY,
isAudience,
CLAN_AUDIENCES,
DEFAULT_CLAN_ROSTER,
CLAN_ROSTER_KEY,
isClanAudience,
clanRosterAudience,
meets,
normalise,
viewerLevel,
fleetPresence,
presenceFor,
canSeePresence,
describe,
update,
}