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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user