// ── The player's own Rust identity ──────────────────────────────────────── // // `/player/rust` — where a signed-in player links the Steam account they play // on. It is the one page in this module a player is asked to *do* something on, // and the thing they are doing matters more than it looks: from phase 7 the link // is what in-game permissions are granted against, and from phase 13 it is what // rewards are handed to. // // **A player route renders no layout of its own.** Core wraps `/player/*` in its // own portal chrome, so this page starts at a heading — unlike the public pages // in this module, which render `PublicLayout` themselves. // // The three-step instruction at the top is not decoration. Nothing else on the // site tells a player that the code comes from the game, and a code field with no // explanation is a code field nobody can use. import { useCallback, useState } from 'react' import { ErrorState, Loading, useAsync } from '../../core.js' import { ago, coins, shortId } from '../../lib/format.js' import api from '../../api.js' import Backpack from '../../components/Backpack.jsx' /** The code field, and the four answers it can produce. */ function LinkForm({ onLinked }) { const [code, setCode] = useState('') const [busy, setBusy] = useState(false) const [message, setMessage] = useState('') const [error, setError] = useState('') async function submit(event) { event.preventDefault() if (!code.trim() || busy) return setBusy(true) setMessage('') setError('') try { const result = await api.playerLinks.confirm(code.trim()) setMessage( result.already ? 'That account was already linked to you.' : `Linked ${result.link.name || shortId(result.link.steamId)}.`, ) setCode('') await onLinked() } catch (err) { // Every refusal the server sends is already a sentence aimed at a player — // "run /link again", "run /unlink in game", "try again in a minute" — so // this renders it rather than replacing it with one of its own. The three // are not interchangeable, and a page that flattened them into "could not // link that code" would send a player back to the server that is down. setError(err.message || 'Could not link that code.') } finally { setBusy(false) } } return (
{message && (

{message}

)} {error && (

{error}

)}
) } /** One linked account, and the control that releases it. */ function LinkRow({ link, onRemoved }) { const [busy, setBusy] = useState(false) const [error, setError] = useState('') async function remove() { setBusy(true) setError('') try { await api.playerLinks.remove(link.steamId) await onRemoved() } catch (err) { setError(err.message || 'Could not unlink that account.') setBusy(false) } } return (
  • {link.name || shortId(link.steamId)}
    {link.steamId} · linked {ago(link.linkedAt)} {link.serverId ? ` on ${link.serverId}` : ''}
    {error && (

    {error}

    )}
  • ) } /** * Where an entitlement has actually landed. * * The server resolves the scope and marks each server, so this renders an answer * rather than working one out — `*` means nothing to a player, and a second * implementation of the scope arithmetic on the client is a second thing to keep * true (see `forPlayer` in the permission model). */ function Reach({ reach }) { if (!reach.length) { return ( No servers are configured yet ) } return (
    {reach.map((server) => ( {/* The word, not only the dot. A filled circle beside a hollow one is the whole difference between "you have this in game" and "you do not yet", which is more than a shape should have to carry — and a reader who cannot tell the two apart gets no answer at all. */} {server.live ? '● ' : '○ '} {server.name} · {server.live ? 'has it' : 'waiting'} ))}
    ) } /** One group or one direct grant, drawn the same way because they read the same. */ function HeldRow({ title, subtitle, permissions, reach }) { return (
  • {title}
    {subtitle && (
    {subtitle}
    )} {permissions && permissions.length > 0 && (
    {permissions.join(' · ')}
    )}
  • ) } /** * What the site has given this player in game. * * Its own read, not part of the links read: an entitlement exists whether or not * a Steam account is linked, and a player who has just been given something and * has not linked yet is exactly the person who needs to see both halves at once. */ function Held({ accounts }) { const { data, loading, error } = useAsync(() => api.playerPermissions.list(), []) if (loading) return if (error) return const groups = data.groups || [] const grants = data.grants || [] if (!groups.length && !grants.length) { return (

    Nothing yet. Ranks and rewards this site hands out show up here, and reach you in game on the servers they cover.

    ) } const waiting = [...groups, ...grants].some((entry) => entry.reach.some((server) => !server.live)) return ( <> {accounts === 0 && (

    None of this reaches the game yet — link a Steam account above and the site pushes it across on its next sync.

    )} {accounts > 0 && waiting && (

    A server marked waiting has not confirmed it yet. One that is offline catches up when it comes back.

    )} ) } /** * RunicNPC (runicnpc stage 4, D252): your kills of each NPC profile, this wipe, * by server, across every Steam account you have linked. Nothing without a link. */ function NpcKills() { const { data, loading, error } = useAsync(() => api.playerNpcKills.list(), []) if (loading) return if (error) return const rows = (data && data.kills) || [] if (rows.length === 0) { return

    None this wipe.

    } const byServer = new Map() for (const r of rows) byServer.set(r.server || r.serverId, [...(byServer.get(r.server || r.serverId) || []), r]) return (
      {[...byServer.entries()].map(([serverId, list]) => (
    • {serverId}: {list.map((k, i) => ( {i > 0 && · } {k.label} {k.kills} ))}
    • ))}
    ) } /** * Economics (PLAN_REDESIGNS §11.2.2): your coins on each server, per linked * account. Live where the server answers; otherwise the last balance it * reported, marked so. A server without Economics says so (D199) rather than * vanishing — a player asking "where are my coins" is owed the reason. */ function Coins() { const { data, loading, error } = useAsync(() => api.playerEconomics.list(), []) if (loading) return if (error) return const list = (data && data.servers) || [] if (list.length === 0) return

    No servers yet.

    return (
      {list.map((server) => (
    • {server.server}: {!server.economics ? ( {server.reason} ) : ( server.accounts.map((a, i) => ( {i > 0 && · } {server.accounts.length > 1 && {a.name || shortId(a.steamId)} } {coins(a.balance)} {a.source === 'last-known' && (last known — the server did not answer)} )) )}
    • ))}
    ) } /** * Backpacks (PLAN_REDESIGNS §11.3.2): your own backpack, from one of your * linked accounts, read live when you open it. A server without Backpacks * says so when asked (D199). */ function OwnBackpack({ links }) { const { data } = useAsync(() => api.playerServers.list(), []) const list = (data && data.servers) || [] if (!list.length) return null return ( ({ id: s.id, name: s.name }))} accounts={links.map((l) => ({ steamId: l.steamId, name: l.name }))} read={(serverId, steamId) => api.playerBackpacks.read(serverId, steamId)} /> ) } export default function Account() { // `useAsync` rather than this module's `usePolled`: nothing here changes unless // the person looking at it changes it, and a page that re-asked every twenty // seconds would be asking a question nobody is waiting on. // // **Core's `useAsync` has no `refresh`** — it re-runs when its deps change and // that is the whole of its interface — so a counter in the deps is how a page // re-reads after its own write. It blanks while it re-reads, which is right // here and is exactly what made it wrong for a poll (see `hooks/usePolled.js`). const [reloads, setReloads] = useState(0) const { data, loading, error } = useAsync(() => api.playerLinks.list(), [reloads]) const links = data ? data.links : [] const reload = useCallback(() => setReloads((n) => n + 1), []) return (
    Steam accounts

    Linking tells this site which Steam account is yours, so your play on our servers appears under your name here — and so rewards and permissions the site hands out can reach you in game.

    1. Join any of our Rust servers and type /link in chat.
    2. The server replies with a six-character code, only you can see it, and it lasts five minutes.
    3. Type it below. It works once.
    {/* D392: core's "Link Steam" is a second way to make this link. Unlinking Steam there removes only the sign-in (D393), so this page says where a Rust link is removed. */}

    If this site offers Steam sign-in, linking Steam on your account page links that Steam account here too. Unlinking it there only stops you signing in with it; remove a Rust link here.

    {loading && } {error && } {data && links.length > 0 && (
      {links.map((link) => ( ))}
    )} {data && links.length > 0 && (

    A link covers every server this community runs — a Steam account is one person wherever they play, while stats are kept per server and per wipe. You can also type {' '}/unlink in game to release one.

    )} {data && links.length === 0 && (

    No Steam account is linked to this profile yet.

    )} {/* Phase 8. Rendered whether or not anything is linked: an entitlement is authored against the website account, so it exists before a Steam id does — and hiding it until one appears is the mistake the admin user page shipped in phase 7 (PLAN.md §20.5). */}
    What you can do in game
    {data && }
    NPCs you have killed
    {data && links.length > 0 ? :

    Link a Steam account to see them.

    }
    Coins
    {data && links.length > 0 ? :

    Link a Steam account to see your balance.

    }
    Backpack
    {data && links.length > 0 ? :

    Link a Steam account to see your backpack.

    }
    ) }