Files
Module-Rust/server/demoSeed.js
wtclaude 1f4ad25edb
All checks were successful
PR Checks / server-tests (pull_request) Successful in 55s
PR Checks / client-build (pull_request) Successful in 1m7s
PR Checks / frozen-manifest (pull_request) Successful in 1m36s
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
2026-10-27 20:15:32 -05:00

191 lines
9.4 KiB
JavaScript

// ── The public demo's seed: this module's rows (MODULE_API 1.18.0, DM15) ────
//
// `npm run seed:demo` hands each module its `modules.<id>` section of the
// fixture, with every `"$env:NAME"` already replaced, and the demo accounts' ids
// (DEMO_MODE.md §7.3). This module writes two kinds of row from it, through its
// own models; core never writes them:
//
// servers the link to the rig: the sidecar URL and token come from the host's
// .env, so the token is never in the repo
// links demo-player's stand-in Steam id (DM6), so the player screens have
// 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",
// "sidecarToken": "$env:RUST_SIDECAR_TOKEN" }],
// "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
// first run only, but calls this every time. The section's shape is checked
// 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 servers = require('./model/servers/servers.model')
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 core = require('./core')
const log = core.logger('demo')
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
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. */
function checkSection(data, accounts = {}) {
const problems = []
if (!data || typeof data !== 'object' || Array.isArray(data)) return ['modules.rust must be an object']
const list = (key) => (data[key] === undefined ? [] : data[key])
for (const key of ['servers', 'links', 'chat']) {
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 (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) => {
const at = `modules.rust.servers[${i}]`
if (!s || typeof s !== 'object') return problems.push(`${at} must be an object`)
if (!SERVER_ID.test(String(s.id || ''))) problems.push(`${at}.id must be lowercase letters, digits and hyphens`)
if (typeof s.name !== 'string' || !s.name.trim()) problems.push(`${at}.name is required`)
if (!/^https?:\/\//.test(String(s.sidecarBaseUrl || ''))) problems.push(`${at}.sidecarBaseUrl must be an http(s) URL`)
if (typeof s.sidecarToken !== 'string' || !s.sidecarToken) problems.push(`${at}.sidecarToken is required`)
return undefined
})
list('links').forEach((l, i) => {
const at = `modules.rust.links[${i}]`
if (!l || typeof l !== 'object') return problems.push(`${at} must be an object`)
if (!accounts[l.username]) problems.push(`${at}.username must be one of the demo accounts`)
if (!STEAM_ID.test(String(l.steamId || ''))) problems.push(`${at}.steamId must be a 17-digit Steam id`)
if (l.serverId !== undefined && !SERVER_ID.test(String(l.serverId))) problems.push(`${at}.serverId must be a server id`)
return undefined
})
return problems
}
/** The hook core calls: `onDemoSeed(data, { accounts })`. */
async function onDemoSeed(data, { accounts = {} } = {}) {
const problems = checkSection(data, accounts)
if (problems.length) throw new Error(`the Rust demo seed has problems: ${problems.join('; ')}`)
for (const s of data.servers || []) {
await serversDb.upsertServer({
id: s.id,
name: s.name.trim(),
sidecarBaseUrl: s.sidecarBaseUrl,
sidecarTokenEnc: servers.encryptToken(s.sidecarToken),
protocol: s.protocol === undefined ? sidecar.PROTOCOL_VERSION : Number(s.protocol),
enabled: s.enabled === undefined ? true : Boolean(s.enabled),
sortOrder: s.sortOrder === undefined ? 0 : Number(s.sortOrder),
})
log.info('demo server ready', { server: s.id })
}
// A link is a plain insert by design (D23), so the upsert is spelled out here:
// the right owner already holds it, or it is moved to them. Only the seed may
// move a link, and only to a demo account.
for (const l of data.links || []) {
const userId = accounts[l.username]
const steamId = String(l.steamId)
const existing = await linksDb.getBySteamId(steamId)
if (existing && Number(existing.userId) === Number(userId)) continue
if (existing) await linksDb.removeBySteamId(steamId)
await linksDb.insert({ steamId, userId, name: l.name || null, serverId: l.serverId || null })
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 }