feat(rust): NPC profiles and placements pages, the profile leaderboard (runicnpc stage 4)

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
This commit is contained in:
2026-09-30 04:42:05 -05:00
parent fa16f0ad2e
commit 7eae4a1e62
13 changed files with 1356 additions and 10 deletions

View File

@@ -34,6 +34,8 @@ export function statOptions(categories) {
return [
...(categories || []).map((c) => ({ id: c.stat, label: c.label, title: c.current })),
{ id: 'playtime', label: 'Playtime', title: null },
// RunicNPC (runicnpc stage 4, D250): one NPC profile's kills. The rule names the profile.
{ id: 'profilekills', label: 'Kills of an NPC profile', title: null },
]
}
@@ -66,6 +68,8 @@ function Chip({ text, color }) {
export function TitlesForm({ server, categories, onSaved, onCancel }) {
const start = server.titles || { mode: 'first', max: 2, rules: [] }
const stats = statOptions(categories)
const { data: npc } = useAsync(() => api.servers.npcProfiles(server.id).catch(() => ({ profiles: [] })), [server.id])
const profiles = (npc && npc.profiles) || []
const [mode, setMode] = useState(start.mode)
const [max, setMax] = useState(start.max)
const [rules, setRules] = useState(start.rules.map((r) => ({ ...r })))
@@ -74,7 +78,7 @@ export function TitlesForm({ server, categories, onSaved, onCancel }) {
const setRule = (i, key) => (e) => {
const value = e.target.value
setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' ? Number(value) : value } : r)))
setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' || key === 'profile' ? Number(value) : value } : r)))
}
const move = (i, by) => setRules((list) => {
const next = [...list]
@@ -117,6 +121,12 @@ export function TitlesForm({ server, categories, onSaved, onCancel }) {
<select value={r.stat} onChange={setRule(i, 'stat')} style={inputStyle} aria-label="Stat">
{stats.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
{r.stat === 'profilekills' && (
<select value={r.profile || ''} onChange={setRule(i, 'profile')} style={inputStyle} aria-label="NPC profile" required>
<option value="" disabled>{profiles.length ? 'Which NPC?' : 'No NPC profile on this server'}</option>
{profiles.map((p) => <option key={p.id} value={p.id}>{p.label} ({p.name})</option>)}
</select>
)}
<span>earn</span>
{/* The category's title is a PLACEHOLDER, never a value: saved as the rule's own text it would stop a rename reaching this rule (§5.5). */}
<input value={r.text} onChange={setRule(i, 'text')} maxLength={24} required={!fallback} placeholder={fallback || 'A title'} style={{ ...inputStyle, width: 160 }} aria-label="Title" />