fix(demo): seed NPC profiles, factions and chat styles (DM26)
The demo's first restored wipes brought the default set's groups and permissions back, but not its NPCs or chat titles. The restored rig's RunicNPC says it is already managed (by the site before this one), so the fresh site adopts nothing (D244) and its first push empties the profiles; the placements then wait on missing profiles. The import never adopts a group's chat style either. onDemoSeed now also takes: - npcs.factions: the faction table, through the NPC model's setFactions; - npcs.profiles: each profile through the model's create/update, so it is checked exactly as the admin page checks it (an existing one is updated); - chat: a BetterChat style per server and group, the defaults filled in. A group the rig's import has not brought in yet is created for that server, and the import then adopts the rig's title, rank and permissions into it. The section's new parts are checked before anything is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -9,18 +9,44 @@
|
|||||||
// .env, so the token is never in the repo
|
// .env, so the token is never in the repo
|
||||||
// links demo-player's stand-in Steam id (DM6), so the player screens have
|
// links demo-player's stand-in Steam id (DM6), so the player screens have
|
||||||
// somebody to show
|
// somebody to show
|
||||||
|
// npcs the NPC faction table and profiles of the demo default set (DM26)
|
||||||
|
// chat a BetterChat style per group (DM26)
|
||||||
//
|
//
|
||||||
// { "servers": [{ "id": "main", "name": "Demo", "sidecarBaseUrl": "$env:RUST_SIDECAR_URL",
|
// { "servers": [{ "id": "main", "name": "Demo", "sidecarBaseUrl": "$env:RUST_SIDECAR_URL",
|
||||||
// "sidecarToken": "$env:RUST_SIDECAR_TOKEN" }],
|
// "sidecarToken": "$env:RUST_SIDECAR_TOKEN" }],
|
||||||
// "links": [{ "username": "demo-player", "steamId": "7656119…", "serverId": "main" }] }
|
// "links": [{ "username": "demo-player", "steamId": "7656119…", "serverId": "main" }],
|
||||||
|
// "npcs": { "factions": [{ "a": "bandits", "b": "guards", "relation": "allied" }],
|
||||||
|
// "profiles": [{ "name": "bandit", "servers": ["main"], "body": { … } }] },
|
||||||
|
// "chat": [{ "serverId": "main", "group": "vip", "style": { "Title": "[VIP]", … } }] }
|
||||||
|
//
|
||||||
|
// **Why NPCs and chat come from here, not the rig's baseline (DM26).** The rig is
|
||||||
|
// restored before the site is wiped, and a fresh site imports what it finds
|
||||||
|
// (§7.1). That holds for permissions, but not for these two. The restored rig's
|
||||||
|
// RunicNPC says it is already managed, by the site before this one, so the new
|
||||||
|
// site adopts nothing (D244) and its first push empties the profiles. The import
|
||||||
|
// never adopts a group's chat style. So the site holds them from the start and
|
||||||
|
// pushes them, and the placements in the baseline spawn again.
|
||||||
|
//
|
||||||
|
// A chat style names a group the rig's import has not brought in yet, because
|
||||||
|
// the seed runs first. The group is created for that server, and the import
|
||||||
|
// then adopts the rig's title, rank and permissions into it. A style lists only
|
||||||
|
// the fields that differ from BetterChat's defaults.
|
||||||
//
|
//
|
||||||
// **It runs on every seed, so it upserts.** Core seeds content on a database's
|
// **It runs on every seed, so it upserts.** Core seeds content on a database's
|
||||||
// first run only, but calls this every time. Everything is checked before
|
// first run only, but calls this every time. The section's shape is checked
|
||||||
// anything is written, and a problem throws, which fails the seed.
|
// before anything is written, and a problem throws, which fails the seed. A
|
||||||
|
// profile is checked by the NPC model as it is saved, as the admin page saves
|
||||||
|
// one, so a profile the model refuses fails the seed too.
|
||||||
|
|
||||||
const linksDb = require('./model/links/links.db')
|
const linksDb = require('./model/links/links.db')
|
||||||
const servers = require('./model/servers/servers.model')
|
const servers = require('./model/servers/servers.model')
|
||||||
const serversDb = require('./model/servers/servers.db')
|
const serversDb = require('./model/servers/servers.db')
|
||||||
|
const npcs = require('./model/npcs/npcs.model')
|
||||||
|
const npcsDb = require('./model/npcs/npcs.db')
|
||||||
|
const npcShape = require('./model/npcs/npcProfile')
|
||||||
|
const permApply = require('./model/permissions/permissions.apply')
|
||||||
|
const permDb = require('./model/permissions/permissions.db')
|
||||||
|
const chatStyle = require('./model/permissions/chatStyle')
|
||||||
const sidecar = require('./sidecarClient')
|
const sidecar = require('./sidecarClient')
|
||||||
const core = require('./core')
|
const core = require('./core')
|
||||||
|
|
||||||
@@ -28,6 +54,13 @@ const log = core.logger('demo')
|
|||||||
|
|
||||||
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
|
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
|
||||||
const STEAM_ID = /^7656119\d{10}$/
|
const STEAM_ID = /^7656119\d{10}$/
|
||||||
|
// Oxide group names as the permission model keeps them: trimmed and lower-cased.
|
||||||
|
const GROUP_NAME = /^[a-z0-9_.-]{1,64}$/
|
||||||
|
|
||||||
|
/** BetterChat's defaults for the group, with the fixture's fields over them. */
|
||||||
|
function fullStyle(group, style) {
|
||||||
|
return { ...chatStyle.defaults(String(group || '')), ...(style && typeof style === 'object' ? style : {}) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Every problem with the section, as sentences. Empty when it is good to write. */
|
/** Every problem with the section, as sentences. Empty when it is good to write. */
|
||||||
function checkSection(data, accounts = {}) {
|
function checkSection(data, accounts = {}) {
|
||||||
@@ -35,11 +68,41 @@ function checkSection(data, accounts = {}) {
|
|||||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return ['modules.rust must be an object']
|
if (!data || typeof data !== 'object' || Array.isArray(data)) return ['modules.rust must be an object']
|
||||||
|
|
||||||
const list = (key) => (data[key] === undefined ? [] : data[key])
|
const list = (key) => (data[key] === undefined ? [] : data[key])
|
||||||
for (const key of ['servers', 'links']) {
|
for (const key of ['servers', 'links', 'chat']) {
|
||||||
if (data[key] !== undefined && !Array.isArray(data[key])) problems.push(`modules.rust.${key} must be an array`)
|
if (data[key] !== undefined && !Array.isArray(data[key])) problems.push(`modules.rust.${key} must be an array`)
|
||||||
}
|
}
|
||||||
|
const n = data.npcs
|
||||||
|
if (n !== undefined) {
|
||||||
|
if (!n || typeof n !== 'object' || Array.isArray(n)) problems.push('modules.rust.npcs must be an object')
|
||||||
|
else {
|
||||||
|
if (n.factions !== undefined && !Array.isArray(n.factions)) problems.push('modules.rust.npcs.factions must be an array')
|
||||||
|
if (n.profiles !== undefined && !Array.isArray(n.profiles)) problems.push('modules.rust.npcs.profiles must be an array')
|
||||||
|
}
|
||||||
|
}
|
||||||
if (problems.length) return problems
|
if (problems.length) return problems
|
||||||
|
|
||||||
|
if (n && n.factions !== undefined) {
|
||||||
|
const checked = npcShape.checkFactions(n.factions)
|
||||||
|
if (!checked.ok) problems.push(`modules.rust.npcs.${checked.error}`)
|
||||||
|
}
|
||||||
|
;((n && n.profiles) || []).forEach((p, i) => {
|
||||||
|
const at = `modules.rust.npcs.profiles[${i}]`
|
||||||
|
if (!p || typeof p !== 'object') return problems.push(`${at} must be an object`)
|
||||||
|
if (!npcShape.NAME_RULE.test(String(p.name || ''))) problems.push(`${at}.name must be 1 to 40 of a-z, 0-9, _ and -`)
|
||||||
|
if (!p.body || typeof p.body !== 'object' || Array.isArray(p.body)) problems.push(`${at}.body must be an object`)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
list('chat').forEach((c, i) => {
|
||||||
|
const at = `modules.rust.chat[${i}]`
|
||||||
|
if (!c || typeof c !== 'object') return problems.push(`${at} must be an object`)
|
||||||
|
if (!SERVER_ID.test(String(c.serverId || ''))) problems.push(`${at}.serverId must be a server id`)
|
||||||
|
if (!GROUP_NAME.test(String(c.group || ''))) problems.push(`${at}.group must be a group name`)
|
||||||
|
const checked = chatStyle.validateStyle(fullStyle(c.group, c.style))
|
||||||
|
if (!checked.ok) problems.push(`${at}.style: ${checked.errors.join(' ')}`)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
list('servers').forEach((s, i) => {
|
list('servers').forEach((s, i) => {
|
||||||
const at = `modules.rust.servers[${i}]`
|
const at = `modules.rust.servers[${i}]`
|
||||||
if (!s || typeof s !== 'object') return problems.push(`${at} must be an object`)
|
if (!s || typeof s !== 'object') return problems.push(`${at} must be an object`)
|
||||||
@@ -92,6 +155,36 @@ async function onDemoSeed(data, { accounts = {} } = {}) {
|
|||||||
await linksDb.insert({ steamId, userId, name: l.name || null, serverId: l.serverId || null })
|
await linksDb.insert({ steamId, userId, name: l.name || null, serverId: l.serverId || null })
|
||||||
log.info('demo link ready', { username: l.username })
|
log.info('demo link ready', { username: l.username })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After the servers: a profile is refused for a server the site does not have.
|
||||||
|
const n = data.npcs || {}
|
||||||
|
if (n.factions !== undefined) {
|
||||||
|
await npcs.setFactions(n.factions)
|
||||||
|
log.info('demo NPC factions ready', { pairs: n.factions.length })
|
||||||
|
}
|
||||||
|
if (n.profiles && n.profiles.length) {
|
||||||
|
const existing = await npcsDb.listProfiles()
|
||||||
|
for (const p of n.profiles) {
|
||||||
|
const input = { name: p.name, body: p.body, servers: p.servers, allServers: p.allServers, killsScope: p.killsScope }
|
||||||
|
const same = existing.find((e) => e.name === p.name && !e.replaced)
|
||||||
|
if (same) await npcs.update(same.id, input)
|
||||||
|
else await npcs.create(input)
|
||||||
|
log.info('demo NPC profile ready', { profile: p.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of data.chat || []) {
|
||||||
|
const name = String(c.group)
|
||||||
|
let group = await permApply.groupOn(name, c.serverId)
|
||||||
|
if (!group) {
|
||||||
|
const id = await permDb.insertGroup({ name, title: name })
|
||||||
|
await permDb.setGroupServers(id, { allServers: false, servers: [c.serverId] })
|
||||||
|
group = { id }
|
||||||
|
}
|
||||||
|
await permDb.setGroupChat(group.id, chatStyle.validateStyle(fullStyle(name, c.style)).fields)
|
||||||
|
await permDb.markDirty(c.serverId)
|
||||||
|
log.info('demo chat style ready', { server: c.serverId, group: name })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { checkSection, onDemoSeed }
|
module.exports = { checkSection, onDemoSeed }
|
||||||
|
|||||||
@@ -375,6 +375,76 @@ test('a re-seed leaves a link the right owner holds, and moves one held by someo
|
|||||||
assert.deepEqual(writes, ['DELETE', 'INSERT'])
|
assert.deepEqual(writes, ['DELETE', 'INSERT'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── NPCs and chat styles from the seed (DM26) ──────────────────────────────
|
||||||
|
|
||||||
|
const NPC_SECTION = {
|
||||||
|
npcs: {
|
||||||
|
factions: [{ a: 'bandits', b: 'guards', relation: 'allied' }],
|
||||||
|
profiles: [
|
||||||
|
{ name: 'bandit', servers: ['main'], body: { kits: ['rnpc_raider'], role: 'roamer' } },
|
||||||
|
{ name: 'guard', servers: ['main'], body: { kits: ['rnpc_campguard'], role: 'sentry' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
chat: [
|
||||||
|
{ serverId: 'main', group: 'vip', style: { Title: '[VIP]', TitleColor: '#ffaa00' } },
|
||||||
|
{ serverId: 'main', group: 'moderator', style: { Title: '[MOD]' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stubs the models the seed writes NPCs and chat styles through; returns the calls. */
|
||||||
|
function stubSeedModels({ profiles = [], groups = {} } = {}) {
|
||||||
|
const calls = []
|
||||||
|
const npcs = require('../model/npcs/npcs.model')
|
||||||
|
const npcsDb = require('../model/npcs/npcs.db')
|
||||||
|
const apply = require('../model/permissions/permissions.apply')
|
||||||
|
const permDb = require('../model/permissions/permissions.db')
|
||||||
|
npcs.setFactions = async (f) => calls.push(['setFactions', f])
|
||||||
|
npcs.create = async (input) => calls.push(['create', input.name, input.servers])
|
||||||
|
npcs.update = async (id, input) => calls.push(['update', id, input.name])
|
||||||
|
npcsDb.listProfiles = async () => profiles
|
||||||
|
apply.groupOn = async (name) => groups[name] || null
|
||||||
|
permDb.insertGroup = async ({ name }) => {
|
||||||
|
calls.push(['insertGroup', name])
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
permDb.setGroupServers = async (id, s) => calls.push(['setGroupServers', id, s.servers])
|
||||||
|
permDb.setGroupChat = async (id, style) => calls.push(['setGroupChat', id, style.Title, style.TitleColor, Object.keys(style).length])
|
||||||
|
permDb.markDirty = async (scope) => calls.push(['markDirty', scope])
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the NPC and chat parts of the seed section are checked before anything is written', async () => {
|
||||||
|
withCore()
|
||||||
|
const seed = require('../demoSeed')
|
||||||
|
assert.deepEqual(seed.checkSection(NPC_SECTION, {}), [])
|
||||||
|
const problems = seed.checkSection({
|
||||||
|
npcs: { factions: [{ a: 'scientists', b: 'animals', relation: 'hostile' }], profiles: [{ name: 'Bad Name', body: [] }] },
|
||||||
|
chat: [{ serverId: 'Main', group: '', style: { Title: '[VIP]', Nope: 1 } }],
|
||||||
|
}, {})
|
||||||
|
assert.equal(problems.length, 6, problems.join(' | '))
|
||||||
|
assert.deepEqual(seed.checkSection({ npcs: [] }, {}), ['modules.rust.npcs must be an object'])
|
||||||
|
const calls = stubSeedModels()
|
||||||
|
await assert.rejects(seed.onDemoSeed({ chat: [{ serverId: 'main', group: 'vip', style: { Priority: 'x' } }] }, { accounts: {} }), /style/)
|
||||||
|
assert.deepEqual(calls, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the seed sets the faction table, saves each profile, and styles each group', async () => {
|
||||||
|
withCore()
|
||||||
|
const calls = stubSeedModels({ profiles: [{ id: 7, name: 'bandit', replaced: false }], groups: { vip: { id: 13 } } })
|
||||||
|
await require('../demoSeed').onDemoSeed(NPC_SECTION, { accounts: {} })
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['setFactions', NPC_SECTION.npcs.factions],
|
||||||
|
['update', 7, 'bandit'], // already there: updated, not created twice
|
||||||
|
['create', 'guard', ['main']],
|
||||||
|
['setGroupChat', 13, '[VIP]', '#ffaa00', 12], // all twelve fields, the defaults filled in
|
||||||
|
['markDirty', 'main'],
|
||||||
|
['insertGroup', 'moderator'], // the rig's import has not brought it in yet
|
||||||
|
['setGroupServers', 50, ['main']],
|
||||||
|
['setGroupChat', 50, '[MOD]', '#55aaff', 12],
|
||||||
|
['markDirty', 'main'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
// ── The manifest (§3.2) ────────────────────────────────────────────────────
|
// ── The manifest (§3.2) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('module.json locks the two link routes and needs a core that asks demoRefuses', () => {
|
test('module.json locks the two link routes and needs a core that asks demoRefuses', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user