diff --git a/server/demoSeed.js b/server/demoSeed.js index 829a038..4d791b7 100644 --- a/server/demoSeed.js +++ b/server/demoSeed.js @@ -9,18 +9,44 @@ // .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" }] } +// "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. Everything is checked before -// anything is written, and a problem throws, which fails the seed. +// 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') @@ -28,6 +54,13 @@ 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 = {}) { @@ -35,11 +68,41 @@ function checkSection(data, accounts = {}) { 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']) { + 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`) @@ -92,6 +155,36 @@ async function onDemoSeed(data, { accounts = {} } = {}) { 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 } diff --git a/server/test/demo.test.js b/server/test/demo.test.js index badf1f3..ff4459b 100644 --- a/server/test/demo.test.js +++ b/server/test/demo.test.js @@ -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']) }) +// ── 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) ──────────────────────────────────────────────────── test('module.json locks the two link routes and needs a core that asks demoRefuses', () => {