Files
Module-Rust/server/router/player/rust.router.js
wtclaude fa16f0ad2e feat(rust): NPC profile and placement routes, the event picker, triggers and titles (runicnpc stage 4)
Admin: /admin/rust/npcs for profiles (create, change, delete, restore a
replaced one, push now) and each server's placements (list, add from a map
point, change, remove, rename, respawn). Public: the profiles a leaderboard
ranks by, one profile's ranking counted as the profile says (D247, D250), and
one player's kills by profile (D252). Player: your own kills by profile.

The Place NPCs step offers the site's profiles first, then Rust's own
(D243). rust.npc.died and rust.npc.health are triggers a phase can wait on.
A title rule can rank a profile's kills. Swagger fragment, engagement and
route manifests regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-30 04:34:43 -05:00

132 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Player · Rust ─────────────────────────────────────────────────────────
//
// Mounted at `/api/v1/player/rust`. The tier's gate is already applied: `player`
// sits behind `noindex, requireAuth`, so every handler here has a signed-in user
// and none of them re-implements that check.
//
// ── Why this tier exists in phase 1, and what it holds now ────────────────
//
// R14 puts this module on all three tiers from the start, and the loader holds
// `module.json`'s `mounts` against what is actually registered in **both**
// directions — a declared prefix that never gets a router fails the load. So the
// declaration and the registration land together or not at all.
//
// Phase 1 said this tier would carry the signed-in view of a server — the
// viewer's own linked Steam identity, their own presence, their own entitlements
// — and that identity was a later phase. This is that phase: `/links`, `/link`
// and `DELETE /links/:steamId` are R1, and everything phases 7 and 13 hand out is
// hung off the row they write.
//
// `/servers` stays what it was: the same list the public tier serves, answered on
// the authenticated tier so per-player detail can be added without moving the
// address. It delegates to the same model, so the two cannot drift.
const core = require('../../core')
const express = core.express
const { body, param } = core.validator
const rust = require('./rust.controller')
const { validate, rateLimit } = core.middleware
const playerRustRouter = express.Router()
// A Steam id as the game states it — `BasePlayer.UserIDString`, a 17-digit
// SteamID64. Bounded rather than pinned at 17 because the column is a string and
// a test rig's ids are shorter; what matters is that nothing but digits reaches a
// `WHERE steam_id = ?`.
const STEAM_ID_RE = /^[0-9]{5,32}$/
/**
* R1 requires the link code be rate-limited, and this is where that lands.
*
* The code is six characters from a 32-glyph alphabet, so guessing one is a
* 1-in-10⁹ shot — but only while the guesser is made to pay for each attempt.
* Ten per quarter-hour per IP turns that into centuries; without it a script
* could work through the space in an afternoon, and phases 7 and 13 make the
* prize a set of in-game permissions and entitlements rather than a cosmetic
* badge.
*
* Its own limiter rather than core's `accountChangeLimiter`: this is guessing
* somebody else's secret, not changing your own password, and sharing a counter
* would mean one of the two silently sets the policy for the other.
*/
const linkLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
label: 'rust-link-code',
message: 'Too many link attempts. Please try again later.',
})
playerRustRouter.get(
'/npc-kills',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'Your kills of each NPC profile'
// #swagger.description = 'The caller’s kills of RunicNPC’s NPCs, by server and profile, in each server’s current wipe, across every Steam account they have linked (docs/runicnpc/PLAN.md stage 4, D252). Empty with no linked account. Requires a session.'
/* #swagger.responses[200] = { description: 'The kills', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcOwnKills" } } } } */
rust.ownNpcKills,
)
playerRustRouter.get(
'/servers',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'The Rust servers, for a signed-in player'
// #swagger.description = 'The same servers the public list carries, answered on the authenticated tier. It is the address a signed-in client calls, so that per-player detail can be added here without moving it. Requires a session.'
/* #swagger.responses[200] = { description: 'The server list', content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerList" } } } } */
rust.listServers,
)
playerRustRouter.get(
'/links',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'The Steam accounts the caller has linked'
// #swagger.description = 'Every Steam account linked to the signed-in user, newest first. A link is fleet-wide: it is keyed by Steam id, not by server, because a Steam account is one person across every server an operator runs.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkList" } } } } */
rust.listLinks,
)
playerRustRouter.get(
'/permissions',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'What the site has given the caller in game'
// #swagger.description = 'The groups and direct grants the site holds for the signed-in user, each resolved to the servers its scope reaches and marked with whether that server has it yet. Read-only: a grant a player could change would not be a grant. `live` is the pushed ledger rather than the authored row, so an entitlement that has not reached a game reads as waiting — which is also what an offline server, a permission no loaded plugin registered, and an account the store has never seen all look like from here.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'What the caller holds', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPlayerPermissions" } } } } */
rust.listPermissions,
)
playerRustRouter.post(
'/link',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'Link a Steam account with a one-time code from /link in game'
// #swagger.description = 'The player types /link in game, the plugin hands them a six-character code privately, and they enter it here within five minutes. The site asks each configured server in turn until one recognises the code. A Steam account already linked to a different website account is refused rather than moved — the way out is /unlink in game.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkRequest" } } } } */
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/RustLinkResult" } } } } */
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'That Steam account is linked to another website account', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many link attempts', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'A server could not be reached — the code is still good', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
linkLimiter,
body('code').isString().trim().isLength({ min: 4, max: 32 }),
validate,
rust.confirmLink,
)
playerRustRouter.delete(
'/links/:steamId',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'Release a Steam account the caller has linked'
// #swagger.description = 'Removes the caller’s own link. Scoped to the caller in the statement, so a link belonging to somebody else answers the same 404 as one that does not exist.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['steamId'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Steam id to release.' }
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { unlinked: { type: "boolean", example: true } } } } } } */
/* #swagger.responses[404] = { description: 'Not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('steamId').matches(STEAM_ID_RE),
validate,
rust.removeLink,
)
module.exports = playerRustRouter