Admin: Rust NPC profiles (the form RunicNPC reads, per server, shared or fleet, each server's push state and refusals, replaced profiles with Restore) and Rust NPC placements (the live map: click to place, pins for every placement; edit, rename, respawn, remove). The live map takes a pick handler and pins, unchanged for the public page. Public: the leaderboard ranks by a profile's kills (D250), and opening a row shows that player's kills by profile (D252); the killfeed names a RunicNPC NPC by its own name. Player: your own kills by profile. Titles: a rule on a profile's kills picks the profile. npcs.test.js covers the profile checks, adoption, the push, the picker and verb, the triggers, kill crediting, titles and placements (481 server tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
191 lines
7.8 KiB
JavaScript
191 lines
7.8 KiB
JavaScript
// ── Player · Rust — the handlers ──────────────────────────────────────────
|
||
//
|
||
// Two things live here now: the server list as a signed-in caller sees it (phase
|
||
// 1's honest placeholder, which must not reshape the list — it calls the same
|
||
// model the public tier does so the two cannot drift), and R1's identity link.
|
||
//
|
||
// ── Every refusal is a sentence, and they are not interchangeable ─────────
|
||
//
|
||
// The link handler's whole job is turning a discriminated result into the right
|
||
// thing to tell a player, and the four wrong answers are wrong in different ways:
|
||
//
|
||
// • "that code is unknown or expired" → run `/link` again
|
||
// • "another account holds that Steam id" → run `/unlink` in game, or ask staff
|
||
// • "we could not reach a server" → try again in a minute; the code is fine
|
||
// • "no servers are configured" → nothing the player can do at all
|
||
//
|
||
// A player told to run `/link` again when the server their code came from was
|
||
// merely unreachable will run it again, get another code from the same
|
||
// unreachable server, and be told the same thing. That is the failure the
|
||
// `unsure` branch exists to prevent.
|
||
|
||
const core = require('../../core')
|
||
|
||
const links = require('../../model/links/links.model')
|
||
const npcs = require('../../model/npcs/npcs.model')
|
||
const npcsDb = require('../../model/npcs/npcs.db')
|
||
const permissions = require('../../model/permissions/permissions.model')
|
||
const servers = require('../../model/servers/servers.model')
|
||
|
||
const log = core.logger('player')
|
||
|
||
async function listServers(req, res) {
|
||
try {
|
||
res.json({ servers: await servers.listPublic() })
|
||
} catch (err) {
|
||
log.error('failed to read the server list', { error: err.message })
|
||
res.status(500).json({ message: 'Failed to read the server list' })
|
||
}
|
||
}
|
||
|
||
/** GET /player/rust/npc-kills — the caller's kills of each NPC profile, current wipes (D252). */
|
||
async function ownNpcKills(req, res) {
|
||
try {
|
||
const held = await links.listForUser(req.user.id)
|
||
const rows = await npcsDb.ownKills(held.map((l) => String(l.steamId)).filter(Boolean))
|
||
const labels = new Map()
|
||
for (const serverId of new Set(rows.map((r) => r.serverId))) {
|
||
for (const p of await npcs.boardProfiles(serverId)) labels.set(`${serverId} ${p.name}`, p.label)
|
||
}
|
||
const names = new Map((await servers.listPublic()).map((srv) => [srv.id, srv.name || srv.id]))
|
||
res.json({ kills: rows.map((r) => ({ ...r, server: names.get(r.serverId) || r.serverId, label: labels.get(`${r.serverId} ${r.profile}`) || r.profile })) })
|
||
} catch (err) {
|
||
log.error('failed to read a player’s NPC kills', { error: err.message })
|
||
res.status(500).json({ message: 'Failed to read your NPC kills' })
|
||
}
|
||
}
|
||
|
||
/** GET /player/rust/links — the Steam accounts the caller holds. */
|
||
async function listLinks(req, res) {
|
||
try {
|
||
res.json({ links: await links.listForUser(req.user.id) })
|
||
} catch (err) {
|
||
log.error('failed to read a player’s links', { error: err.message })
|
||
res.status(500).json({ message: 'Failed to read your linked accounts' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* POST /player/rust/link — redeem a code from `/link` in game.
|
||
*
|
||
* The fleet loop is the model's (D24); this maps its answer onto a status and a
|
||
* sentence. **A refused code is a 400 and an unreachable server is a 503**,
|
||
* because a client that cannot tell them apart cannot tell a player whether to
|
||
* try again or to go and get a new code.
|
||
*/
|
||
async function confirmLink(req, res) {
|
||
const code = String(req.body.code || '').trim()
|
||
|
||
try {
|
||
const result = await links.redeem({ code, userId: req.user.id })
|
||
|
||
if (result.ok) {
|
||
// Logged on the player tier too, not only for admin writes: this is the
|
||
// moment a website account starts being able to hold permissions and
|
||
// entitlements in a game, and "when did this account become that Steam id"
|
||
// is a question an operator will eventually need answered.
|
||
await core.activity.log({
|
||
req,
|
||
action: 'rust.account.link',
|
||
detail: { steamId: result.link.steamId, serverId: result.link.serverId },
|
||
})
|
||
|
||
return res.json({ linked: true, link: result.link, already: Boolean(result.already) })
|
||
}
|
||
|
||
switch (result.reason) {
|
||
case 'taken':
|
||
// Naming the holder is deliberate and it is not a leak: the player is
|
||
// signed in, the account named is one they may well own, and without the
|
||
// name the advice ("sign in as that account, or ask staff") is unusable.
|
||
return res.status(409).json({
|
||
message: result.username
|
||
? `That Steam account is already linked to ${result.username}. Run /unlink in game to release it.`
|
||
: 'That Steam account is already linked to another website account. Run /unlink in game to release it.',
|
||
})
|
||
|
||
case 'unsure':
|
||
return res.status(503).json({
|
||
message:
|
||
'One of the servers could not be reached, so that code could not be checked. ' +
|
||
'Your code is still good — try again in a minute.',
|
||
})
|
||
|
||
case 'offline':
|
||
return res.status(503).json({
|
||
message: 'The game servers are unreachable right now — try again in a minute.',
|
||
})
|
||
|
||
case 'no-servers':
|
||
return res.status(503).json({ message: 'No Rust servers are configured on this site yet.' })
|
||
|
||
default:
|
||
return res.status(400).json({
|
||
message: 'That code is unknown or has expired. Type /link in game for a new one.',
|
||
})
|
||
}
|
||
} catch (err) {
|
||
log.error('failed to confirm a link code', { error: err.message })
|
||
return res.status(500).json({ message: 'Failed to confirm that code' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* DELETE /player/rust/links/:steamId — release a link the caller holds.
|
||
*
|
||
* Scoped to the caller inside the statement, so "not linked" and "not yours"
|
||
* answer the same 404 — a signed-in stranger must not be able to discover which
|
||
* Steam ids are linked by deleting them one at a time.
|
||
*/
|
||
async function removeLink(req, res) {
|
||
const { steamId } = req.params
|
||
|
||
try {
|
||
const removed = await links.unlinkOwned(steamId, req.user.id)
|
||
|
||
if (!removed) return res.status(404).json({ message: 'That account is not linked to you' })
|
||
|
||
await core.activity.log({ req, action: 'rust.account.unlink', detail: { steamId } })
|
||
|
||
return res.json({ unlinked: true })
|
||
} catch (err) {
|
||
log.error('failed to unlink', { error: err.message })
|
||
return res.status(500).json({ message: 'Failed to unlink that account' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* GET /player/rust/permissions — what the site has given this player in game.
|
||
*
|
||
* Phase 7 made the website the author of in-game privilege and gave an operator
|
||
* every view of it; this is the other side of that, and it is the first time a
|
||
* player can see what they hold without asking one. Read-only by construction:
|
||
* nothing a player can do here changes a grant, because a grant they could
|
||
* change would not be a grant.
|
||
*
|
||
* The caller's Steam ids come from the link model rather than the permission
|
||
* one, so the two questions stay in the files that own them — and the pushed
|
||
* ledger is keyed by Steam id, which is the whole reason this read needs them.
|
||
*/
|
||
async function listPermissions(req, res) {
|
||
try {
|
||
const [accounts, serverRows] = await Promise.all([
|
||
links.listForUser(req.user.id),
|
||
servers.listPublic(),
|
||
])
|
||
|
||
const held = await permissions.forPlayer(
|
||
req.user.id,
|
||
accounts.map((account) => account.steamId),
|
||
serverRows,
|
||
)
|
||
|
||
res.json({ ...held, accounts: accounts.length })
|
||
} catch (err) {
|
||
log.error('failed to read a player’s entitlements', { error: err.message })
|
||
res.status(500).json({ message: 'Failed to read what you hold in game' })
|
||
}
|
||
}
|
||
|
||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills }
|