// ── The public demo's seed: this module's rows (MODULE_API 1.18.0, DM15) ──── // // `npm run seed:demo` hands each module its `modules.` 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 }