diff --git a/client/src/lib/feed.js b/client/src/lib/feed.js index 32666f9..c671949 100644 --- a/client/src/lib/feed.js +++ b/client/src/lib/feed.js @@ -40,6 +40,10 @@ export const FEED_KINDS = Object.freeze([ 'server.wipe', 'server.initialized', 'server.shutdown', + // RunicNPC bosses (runicnpc stage 6): the game says both to the whole server. + // The death names its killer, so the server shows it only where presence is on. + 'npc.boss.spawned', + 'npc.boss.died', ]) /** The filters the feed offers, and the kinds each one asks the API for. */ @@ -53,6 +57,7 @@ export const FILTERS = Object.freeze([ kinds: ['player.connected', 'player.disconnected', 'player.respawned'], }, { id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] }, + { id: 'bosses', label: 'Bosses', kinds: ['npc.boss.spawned', 'npc.boss.died'] }, ]) export function kindsFor(filterId) { @@ -128,6 +133,17 @@ export function describe(row) { case 'server.shutdown': return { tone: 'server', actor: null, verb: 'The server went down', subject: null, detail: '' } + // A boss's name is the profile's, as typed (`name`), never a prefab's. + case 'npc.boss.spawned': + return { tone: 'server', actor: name, verb: 'has appeared', subject: null, detail: 'a boss' } + + case 'npc.boss.died': { + const fought = Number(frame.damagers) > 1 ? `${frame.damagers} players fought it` : '' + return frame.killerName + ? { tone: 'kill', actor: frame.killerName, verb: 'killed', subject: name, detail: fought } + : { tone: 'death', actor: name, verb: 'was killed', subject: null, detail: fought } + } + default: return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' } } diff --git a/client/src/routes/admin/NpcProfiles.jsx b/client/src/routes/admin/NpcProfiles.jsx index 5f225b5..3cc0bea 100644 --- a/client/src/routes/admin/NpcProfiles.jsx +++ b/client/src/routes/admin/NpcProfiles.jsx @@ -63,7 +63,43 @@ function relationsFrom(text) { } const list = (text) => String(text || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean) +const lines = (text) => String(text || '').split('\n').map((s) => s.trim()).filter(Boolean) const str = (v) => (v === undefined || v === null ? '' : String(v)) +/** An optional number as typed: blank is "not set". */ +const opt = (v) => (String(v).trim() === '' ? undefined : v) + +/** A boss phase as the form edits it: `at` in per cent, every other part optional (D275). */ +function phaseForm(p = {}) { + const r = p.ranges || {} + return { + at: p.at === undefined ? '' : str(Math.round(p.at * 100)), + damageDealt: str(p.damageDealt), + aimCone: str(p.aimCone), + speed: str(p.speed), + kit: str(p.kit), + addsProfile: str(p.adds && p.adds.profile), + addsCount: str(p.adds && p.adds.count), + line: str(p.line), + sense: str(r.sense), + loseTarget: str(r.loseTarget), + chase: str(r.chase), + attack: str(r.attack), + } +} + +function phaseBody(p) { + const out = { at: Number(p.at) / 100 } + if (opt(p.damageDealt) !== undefined) out.damageDealt = p.damageDealt + if (opt(p.aimCone) !== undefined) out.aimCone = p.aimCone + if (opt(p.speed) !== undefined) out.speed = p.speed + if (p.kit.trim()) out.kit = p.kit.trim() + if (p.addsProfile.trim()) out.adds = { profile: p.addsProfile.trim(), count: p.addsCount } + if (p.line.trim()) out.line = p.line.trim() + if ([p.sense, p.loseTarget, p.chase, p.attack].some((v) => String(v).trim() !== '')) { + out.ranges = { sense: p.sense, loseTarget: p.loseTarget, chase: p.chase, attack: p.attack } + } + return out +} function formFrom(p, defaults) { const b = { ...defaults, ...(p ? p.body : {}) } @@ -99,6 +135,17 @@ function formFrom(p, defaults) { hurtByPlayers: b.hurtByPlayers !== false, hurtsPlayers: b.hurtsPlayers !== false, kitUse: { ...(defaults.kitUse || {}), ...(b.kitUse || {}) }, + // Stage 6 (D273–D278). + bossOn: Boolean(b.boss), + barDistance: str(b.boss ? b.boss.barDistance : 100), + announce: (b.boss && b.boss.announce) || 'chat', + spawnLine: str(b.boss && b.boss.spawnLine), + deathLine: str(b.boss && b.boss.deathLine), + phases: ((b.boss && b.boss.phases) || []).map(phaseForm), + useMode: (b.use && b.use.mode) || 'chat', + useLines: ((b.use && b.use.lines) || []).join('\n'), + useTitle: str(b.use && b.use.title), + useText: str(b.use && b.use.text), } } @@ -130,10 +177,22 @@ function bodyFrom(f) { hurtByPlayers: f.hurtByPlayers, hurtsPlayers: f.hurtsPlayers, kitUse: f.kitUse, + boss: + f.bossOn && f.role !== 'passive' + ? { barDistance: f.barDistance, announce: f.announce, spawnLine: f.spawnLine, deathLine: f.deathLine, phases: f.phases.map(phaseBody) } + : null, + use: f.role === 'passive' ? { mode: f.useMode, lines: lines(f.useLines), title: f.useTitle, text: f.useText } : null, }, } } +/** A role change: a passive NPC starts unhurtable and standing (D279, D288); leaving passive, it walks again. */ +function withRole(f, role) { + if (role === 'passive' && f.role !== 'passive') return { ...f, role, hurtByPlayers: false, moveMode: 'stand' } + if (role !== 'passive' && f.role === 'passive') return { ...f, role, hurtByPlayers: true, moveMode: f.moveMode === 'stand' ? 'wander' : f.moveMode } + return { ...f, role } +} + /** One line on a server's RunicNPC and the last push to it. */ function serverLine(s) { if (!s.ready) return s.absence || 'no RunicNPC' @@ -259,7 +318,7 @@ export default function NpcProfiles() { - {p.body.role} · {p.body.health} health · kits {(p.body.kits || []).join(', ')} · {p.body.role === 'sentry' ? 'stands still' : p.body.movement && p.body.movement.mode} + {p.body.role}{p.body.boss ? ' · boss' : ''} · {p.body.health} health · kits {(p.body.kits || []).join(', ')} · {p.body.role === 'sentry' ? 'stands still' : p.body.movement && p.body.movement.mode} {' · '}kills counted: {p.killsScope === 'server' ? 'per server' : p.killsScope === 'name' ? 'every server with this name' : 'this profile only'} @@ -320,6 +379,62 @@ function Num({ value, onChange, width = 110 }) { return onChange(e.target.value)} inputMode="decimal" style={{ ...inputStyle, maxWidth: width }} /> } +/** + * A boss's phases (D275): each at a health percentage, each part optional. The + * adds' profile is offered from the site's profiles that are not bosses. + */ +function PhaseList({ form, setForm, profiles }) { + const setPhase = (i, key, value) => + setForm((f) => ({ ...f, phases: f.phases.map((p, j) => (j === i ? { ...p, [key]: value } : p)) })) + const remove = (i) => setForm((f) => ({ ...f, phases: f.phases.filter((_, j) => j !== i) })) + const add = () => setForm((f) => ({ ...f, phases: [...f.phases, phaseForm()] })) + const addable = [...new Set(profiles.filter((p) => !p.replaced && !(p.body && p.body.boss) && p.name !== form.name).map((p) => p.name))].sort() + const row = { display: 'flex', gap: 12, flexWrap: 'wrap' } + + return ( +
+ Phases +

+ When its health falls to a phase’s percentage, the phase’s changes apply from then on. Leave a part blank to keep it as it was. A + phase’s line is said only to the players near it and those who have hurt it. +

+ {form.phases.map((p, i) => ( +
+
+ setPhase(i, 'at', v)} width={80} /> + setPhase(i, 'damageDealt', v)} width={80} /> + setPhase(i, 'aimCone', v)} width={80} /> + setPhase(i, 'speed', v)} width={80} /> + setPhase(i, 'kit', e.target.value)} style={{ ...inputStyle, maxWidth: 160 }} /> +
+
+ + + + {p.addsProfile && setPhase(i, 'addsCount', v)} width={60} />} + setPhase(i, 'sense', v)} width={70} /> + setPhase(i, 'loseTarget', v)} width={70} /> + setPhase(i, 'chase', v)} width={70} /> + setPhase(i, 'attack', v)} width={70} /> +
+
+ + setPhase(i, 'line', e.target.value)} maxLength={256} style={{ ...inputStyle, width: 420, maxWidth: '100%' }} /> + + +
+
+ ))} +
+ +
+
+ ) +} + function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDelete }) { const servers = data.servers || [] const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })) @@ -367,10 +482,11 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel - setForm((f) => withRole(f, e.target.value))} style={inputStyle}> + @@ -380,11 +496,18 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel
Moving
- {form.role === 'roamer' && ( + {(form.role === 'roamer' || form.role === 'passive') && ( <> - + for a route recorded in game${form.role === 'passive' ? ', or stand: it stays where it is placed and turns to whoever talks to it' : ''}. A placement may change it.`} + > set('moveMode')(e.target.value)} list="rnpc-modes" style={{ ...inputStyle, maxWidth: 220 }} /> - + + {form.role === 'passive' && @@ -418,6 +541,67 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel
+ {form.role === 'passive' && ( +
+ Press E +

+ What a player gets for pressing E while looking at it, from up to 3 m. It never fights, whatever happens to it.{' '} + {'{player}'} is the player’s name. +

+ + + + {form.useMode === 'chat' ? ( + +