diff --git a/CHANGELOG.md b/CHANGELOG.md index c29fd12e..5dec4588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **Conditions as part of a game system.** A custom system now carries its conditions: ten + standard ones every system starts with (Blinded, Bleeding, Poisoned, Prone, Stunned, Grappled, + Frightened, Unconscious, Restrained, Exhausted), each of which can be renamed, described, given + modifiers and an end, or turned off, plus its own, up to 60 in all. A condition's icon is one of + 24 drawn for CITY_NET, or a small picture the GM uploads. It ends when removed or after a number + of rounds, and its modifiers name a stat, a formula or all rolls; they're recorded now and will + reach rolls once a system's own rolls are built. The builder's page for them, and conditions on + tokens, come next. + - **Start a system from a genre starter.** In the builder's + NEW, A GENRE STARTER now works like A BUILT-IN EXAMPLE: a card for Sword & Spell, Starfarer and Story First saying what each holds, COPY under your own name, and LOOK FIRST to read one locked, marked as a starter. diff --git a/README.md b/README.md index a48d86a8..cb3267e3 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,7 @@ CITY_NET/ │ │ ├── signs.js # Custom sign CRUD (GET all / POST / PATCH :id / DELETE :id); text optional when image_url set; rotation_x/y/z persisted, non-finite angles rejected │ │ ├── fonts.js # Font file upload/list/delete (.ttf .otf .woff .woff2); served as static under /uploads/fonts/ │ │ ├── player.js # Player auth (register, login, forgot, reset, registration status poll) -│ │ ├── systems.js # Custom game systems: list, create (from a name, a whole definition, or a copy of a built-in example or genre starter), read, save a draft, publish, rename, duplicate, delete (a hide, refused for the running system); export a published system as a .citysys file, preview a file, install it as new, an update or a second copy; upload a currency icon (PNG, WebP or SVG, 0.25MB, stored by content hash) and set or clear a currency's icon in the draft and running copy alike; the builder's previews, which change nothing (a draft's values from its sample, its sheet as drawn, an NPC tier rolled for a level, health tried on a pretend token); the examples and starters, listed and opened. Main admin only, reading included +│ │ ├── systems.js # Custom game systems: list, create (from a name, a whole definition, or a copy of a built-in example or genre starter), read, save a draft, publish, rename, duplicate, delete (a hide, refused for the running system); export a published system as a .citysys file, preview a file, install it as new, an update or a second copy; upload a currency or condition icon (PNG, WebP or SVG, 0.25MB, stored by content hash, one route for both) and set or clear a currency's icon in the draft and running copy alike; the builder's previews, which change nothing (a draft's values from its sample, its sheet as drawn, an NPC tier rolled for a level, health tried on a pretend token); the examples and starters, listed and opened. Main admin only, reading included │ │ └── sheets.js # Character sheets — admin sheet access, NPC library, portraits, LUCK/Edge reset & grant, import preview; a friendly NPC's sheet, read-only, for the player the GM gave it to (GET /npcs/controlled/:location_id). The table-wide resets scan to decide who is affected and then work out each value as that sheet is written, rather than writing back a scan that has already gone stale │ ├── dice/ │ │ └── systemDice.js # Built-in dice manifest keyed by game system (ids namespaced `builtin:`); lives in code, not the DB, so app updates change definitions with no migration and nothing is mutable through the API @@ -416,7 +416,7 @@ CITY_NET/ │ │ ├── derived.js # Checks a definition (every problem at once, with where it is, loops shown as a path), orders values by what they read, and works them out. apply() keeps the contract of the hand-written recompute functions │ │ ├── rules.js # Code-backed rule values a formula can name as $name, for what arithmetic cannot read (installed armor mods, fitted chrome, adept powers). A GM picks from this list, never adds to it │ │ ├── definitions.js # The built-in systems' derived values restated as data: CWN's and Shadowrun's entry for entry in the order their functions write them, Cyberpunk RED's EMP from Humanity (agreeing whenever Humanity is written as a number, its three differences written down), and Generic's nothing -│ │ ├── definition.js # The system definition format (1: name, description, author, license, words, parts, buildings, currencies, bank, stats, samples, lookups, derived, sheet, npc, core) and its server-side checks. Fatal (cannot be stored: not an object, too large, not JSON) vs ordinary problems (saved in a draft, block publishing), all reported with where they are. Also the app's renamable terms and switchable parts, with wordFor / partOn +│ │ ├── definition.js # The system definition format (1: name, description, author, license, words, parts, buildings, currencies, bank, stats, samples, lookups, derived, sheet, npc, core, conditions) and its server-side checks. Fatal (cannot be stored: not an object, too large, not JSON) vs ordinary problems (saved in a draft, block publishing), all reported with where they are. Also the app's renamable terms and switchable parts, with wordFor / partOn │ │ ├── sheet.js # A custom system's character sheet as data (tabs, header, sections of text/number/textarea/select fields with visibility, combat sensitivity, who edits it (the owner, or only the GM), max pairs and token/bank links) and its checks; a starter sheet for a system that has none │ │ ├── terms.js # The glossary: the 13 terms a system may rename, their neutral defaults (for the builder), and ownWords, only the terms a system renamed (for the running game) │ │ ├── parts.js # The parts of the app a system can turn off (bank, shops, vehicles...) and partOn(definition, part); nothing required, so the checks, the starter sheet and the runtime all use it @@ -429,6 +429,7 @@ CITY_NET/ │ │ ├── npc.js # A custom system's NPCs as data: an optional stat-block layout (checked like a sheet, and linking shared fields the same way) and GENERATE_SHEET tiers (label, token HP and defense, starting values), each box a number, a formula with @level, or dice (rolled by tierRolls.js) │ │ ├── runtime.js # Published systems in memory for the running game: compiled once into the meta the built-in templates carry (public/combat/linked/GM-only fields, max pairs, derived recompute), reached by sheets/templates.js through a hook; the NPC tiers, reached by sheets/npcTiers.js the same way; the render copy the browser draws from, with no formulas and every word resolved; and wordIn(system, term, form, today's text) for text the server writes; partIn(system, part) for whether a part of the app is on (always, under a built-in system) │ │ ├── citysys.js # A system as a file to share (.citysys): plain JSON with a cover (name, author, version, builder, license, origin); read as untrusted input, capped, and checked like the editor's work; never carries characters +│ │ ├── conditions.js # A system's conditions: the standard set every system starts with (Blinded, Bleeding, Poisoned... ten in all), each renamed, described, given modifiers and an end or turned off, and the system's own, up to 60 in all. A definition stores only its changes; conditionsOf gives each one whole. Icons are one of 24 drawn for CITY_NET or an uploaded picture; modifiers name a stat, a formula or all rolls and are recorded until a system's rolls are built │ │ ├── stats.js # A system's stats: the numbers players fill in, in groups (a skill may be tied to its ability), names for derived values, and the builder's sample character; until a system designs its own sheet, each group is a section of the starter sheet │ │ ├── tierRolls.js # An NPC tier worked out for a level: each box a number, a formula reading @level, or dice ("3d6", "@level d8 + 4"), rolled and clamped to the limits, with every die kept so the builder's TRY IT can show it │ │ ├── tryHealth.js # TRY IT's health: a made-up character's damage and healing on a pretend token under a draft's own health model, through the same rules the game runs (health.js), drawn as the HEALTH folder draws it for the owner and for everyone else. Nothing read or written @@ -473,6 +474,7 @@ CITY_NET/ │ ├── sheets.test.js # Sheet routes (system switch, admin access, portraits, derived fields, GET /own player self-fetch) │ ├── system_builder_parity.test.js # CWN and Shadowrun as data against cwnRecompute and sr6Recompute over 3,000 seeded sheets each (blank, text, decimal, huge and stale values, broken JSON): same sheet, same changed fields, same order. Cyberpunk RED against applyDerived wherever Humanity is written as a number, its three differences pinned; Generic changing nothing │ ├── system_builder_examples.test.js # The built-in examples ready to publish, their formulas the parity-tested ones and their samples worked out as each game's own code does; the genre starters' formulas, table, NPC rolls, health and credit; the routes listing each kind, opening one and copying it under a new name, main admin only +│ ├── system_builder_conditions.test.js # The standard set, a system's edits and its own resolved in order; every check (names, chip labels, icons drawn or uploaded, ends and rounds, modifiers on stats, formulas or all rolls, the limit of 60); the section in a definition; uploading a condition's icon, main admin only │ ├── system_builder_stats.test.js # Stats in groups (ids, labels, ranges, ties to an ability), names for derived values, the sample character, and the starter sheet's sections from them │ ├── system_builder_preview_values.test.js # A draft's values from its sample while it is still being written: every formula that can be worked out, a mistake shown without blanking the others │ ├── system_builder_preview_sheet.test.js # The CHARACTER SHEET page's preview: a draft's sheet as it stands, and the starter sheet CUSTOMIZE copies diff --git a/backend/__tests__/system_builder_conditions.test.js b/backend/__tests__/system_builder_conditions.test.js new file mode 100644 index 00000000..2a16853b --- /dev/null +++ b/backend/__tests__/system_builder_conditions.test.js @@ -0,0 +1,252 @@ +/** + * A system's conditions as data (4e1a). Approved mockup builder-conditions (2026-10-09): a standard + * set every system starts with, each edited or turned off; a system's own, as many as a table + * needs; icons drawn for CITY_NET or uploaded; ending when removed or after rounds; modifiers on a + * stat, a formula or all rolls, recorded now and applied to rolls with Phase 6. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; +import { makeTestDb } from './helpers/testDb.js'; + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const { STANDARD, ICONS, LIMITS, isIcon, checkConditions, conditionsOf } = require_('../systemBuilder/conditions'); +const { checkDefinition } = require_('../systemBuilder/definition'); +const { LIMITS: UPLOAD } = require_('../middleware/uploadConstraints'); +const { elevatedUsers } = require_('../middleware/auth'); +const systemsRoute = require_('../routes/systems.js'); + +const HASH = 'a'.repeat(64); +const UPLOADED = `/uploads/condition_icons/${HASH}.png`; +const check = (conditions, targets = ['str', 'save']) => { + const problems = []; + checkConditions(conditions, new Set(targets), problems); + return problems; +}; +const messages = (conditions, targets) => check(conditions, targets).map((p) => `${p.where}: ${p.message}`); + +describe('the standard set', () => { + it('is ten conditions every system starts with, each named, labelled and drawn', () => { + expect(STANDARD.map((c) => c.id)).toEqual([ + 'blinded', 'bleeding', 'poisoned', 'prone', 'stunned', 'grappled', 'frightened', 'unconscious', 'restrained', 'exhausted', + ]); + for (const c of STANDARD) { + expect(ICONS, c.id).toContain(c.icon); + expect(c.short.length, c.id).toBeLessThanOrEqual(LIMITS.short); + expect(c.description, c.id).toBeTruthy(); + } + expect(ICONS).toHaveLength(24); + expect(new Set(ICONS).size).toBe(24); + }); + + it('is what a system with no conditions section offers, ending when removed, with no modifiers', () => { + const all = conditionsOf({ format: 1, name: 'Hearth' }); + expect(all.map((c) => c.id)).toEqual(STANDARD.map((c) => c.id)); + expect(all[0]).toEqual({ id: 'blinded', name: 'Blinded', short: 'BLIND', icon: 'blind', description: 'Can\'t see.', ends: 'removed', modifiers: [], standard: true }); + expect(conditionsOf(null)).toHaveLength(STANDARD.length); + }); +}); + +describe('a system\'s conditions, resolved', () => { + const def = { + conditions: { + blinded: { on: false }, + poisoned: { name: 'Toxed', ends: 'rounds', rounds: 3, modifiers: [{ target: 'all_rolls', amount: -1 }] }, + glitching: { name: 'Glitching', icon: UPLOADED, description: 'Chrome misfiring.', modifiers: [{ target: 'str', amount: -2 }] }, + frozen: { name: 'frozen solid', icon: 'snow' }, + nameless: { icon: 'bolt' }, + }, + }; + + it('leaves out the standard ones turned off, keeps the edits, and adds the system\'s own after, in order', () => { + const all = conditionsOf(def); + expect(all.map((c) => c.id)).toEqual([...STANDARD.slice(1).map((c) => c.id), 'glitching', 'frozen']); + expect(all.find((c) => c.id === 'poisoned')).toEqual({ + id: 'poisoned', name: 'Toxed', short: 'POISON', icon: 'skull', description: 'Sickened by a toxin.', + ends: 'rounds', rounds: 3, modifiers: [{ target: 'all_rolls', amount: -1 }], standard: true, + }); + expect(all.find((c) => c.id === 'glitching')).toEqual({ + id: 'glitching', name: 'Glitching', short: 'GLITCHING'.slice(0, 8), icon: UPLOADED, description: 'Chrome misfiring.', + ends: 'removed', modifiers: [{ target: 'str', amount: -2 }], standard: false, + }); + }); + + it('gives one of the system\'s own a chip label from its first word, and an icon when it has none', () => { + const frozen = conditionsOf(def).find((c) => c.id === 'frozen'); + expect(frozen).toMatchObject({ name: 'frozen solid', short: 'FROZEN', icon: 'snow', description: '' }); + expect(conditionsOf({ conditions: { hex: { name: 'Hexed' } } }).find((c) => c.id === 'hex').icon).toBe('target'); + expect(conditionsOf({ conditions: { hex: { name: 'Hexed', short: 'cursed' } } }).find((c) => c.id === 'hex').short).toBe('CURSED'); + }); + + it('keeps a standard name where the edit leaves it blank', () => { + expect(conditionsOf({ conditions: { prone: { name: ' ' } } }).find((c) => c.id === 'prone').name).toBe('Prone'); + }); + + it('carries rounds only on one that ends after rounds, whatever a draft holds', () => { + expect(conditionsOf({ conditions: { prone: { rounds: 2 } } }).find((c) => c.id === 'prone')).not.toHaveProperty('rounds'); + expect(conditionsOf({ conditions: { prone: { ends: 'scene', rounds: 2 } } }).find((c) => c.id === 'prone')).toMatchObject({ ends: 'removed' }); + }); + + it('never offers more than the limit, however many are written', () => { + const many = Object.fromEntries(Array.from({ length: 80 }, (_, i) => [`c${i}`, { name: `C${i}` }])); + expect(conditionsOf({ conditions: many })).toHaveLength(LIMITS.conditions); + }); +}); + +describe('the checks', () => { + it('accept the standard set left alone, edited or turned off, and a system\'s own', () => { + expect(check(undefined)).toEqual([]); + expect(check({ + blinded: { on: false }, + poisoned: { name: 'Toxed', short: 'TOX', icon: 'pill', description: 'Sick.', ends: 'rounds', rounds: 3, modifiers: [{ target: 'all_rolls', amount: -1 }, { target: 'save', amount: 2 }] }, + glitching: { name: 'Glitching', short: 'GLITCH', icon: UPLOADED, description: '', ends: 'removed', modifiers: [] }, + })).toEqual([]); + }); + + it('refuses a section that isn\'t a set, ids that aren\'t ids, and entries that aren\'t conditions', () => { + expect(messages([])).toEqual(['conditions: Must be a set of conditions']); + expect(messages({ 'Bad Id': { name: 'X' }, ok: 'x' })).toEqual([ + 'condition Bad Id: Ids use lowercase letters, digits and _, starting with a letter', + 'condition ok: Must be a condition', + ]); + }); + + it('needs a name on the system\'s own, and keeps every text within its limit', () => { + expect(messages({ hex: {} })).toEqual(['condition hex, name: Required']); + expect(messages({ hex: { name: ' ' } })).toEqual(['condition hex, name: Cannot be blank']); + expect(messages({ hex: { name: 'x'.repeat(31), short: 'TOOLONGXX', description: 'd'.repeat(301) } })).toEqual([ + 'condition hex, name: Longer than 30 characters', + 'condition hex, short: Longer than 8 characters', + 'condition hex, description: Longer than 300 characters', + ]); + expect(messages({ prone: { name: 3 } })).toEqual(['condition prone, name: Must be text']); + }); + + it('turns off only a standard one, and refuses keys that aren\'t part of a condition', () => { + expect(messages({ hex: { name: 'Hexed', on: false } })).toEqual([ + 'condition hex, on: Only a standard condition is turned off; delete one of the system\'s own instead', + ]); + expect(messages({ prone: { on: 'no', color: 'red' } })).toEqual([ + 'condition prone, color: Not part of a condition', + 'condition prone, on: Must say on: true or on: false', + ]); + }); + + it('takes a drawn icon or an uploaded one, nothing else', () => { + for (const ok of [...ICONS, UPLOADED, `/uploads/condition_icons/${HASH}.webp`, `/uploads/condition_icons/${HASH}.svg`]) expect(isIcon(ok), ok).toBe(true); + for (const bad of ['eye', `/uploads/currency_icons/${HASH}.png`, `/uploads/condition_icons/${HASH}.gif`, `/uploads/condition_icons/${'a'.repeat(63)}.png`, + `/uploads/condition_icons/../${HASH}.png`, `https://x.test/uploads/condition_icons/${HASH}.png`, '', 3, null]) { + expect(isIcon(bad), String(bad)).toBe(false); + } + expect(messages({ prone: { icon: 'eye' } })).toEqual(['condition prone, icon: Must be one of the drawn icons or an uploaded one']); + }); + + it('ends when removed or after 1 to 99 rounds, and has rounds only then', () => { + expect(messages({ prone: { ends: 'scene' } })).toEqual(['condition prone, ends: Ends when removed, or after rounds']); + for (const rounds of [0, 100, 2.5, '3', undefined]) { + expect(messages({ prone: { ends: 'rounds', rounds } }), String(rounds)).toEqual(['condition prone, rounds: A whole number from 1 to 99']); + } + expect(check({ prone: { ends: 'rounds', rounds: 99 } })).toEqual([]); + expect(messages({ prone: { rounds: 2 } })).toEqual(['condition prone, rounds: Only a condition that ends after rounds has rounds']); + }); + + it('modifies all rolls, or one of the system\'s stats or formulas, by a whole amount that isn\'t 0', () => { + expect(messages({ prone: { modifiers: [{ target: 'dex', amount: -1 }, { target: 'str', amount: 0 }, { target: 'all_rolls', amount: 100 }, { target: 'str', amount: 1.5 }] } })).toEqual([ + 'condition prone, modifier 1, target: Must be all rolls, or one of this system\'s stats or formulas', + 'condition prone, modifier 2, amount: A whole number from -99 to 99, not 0', + 'condition prone, modifier 3, amount: A whole number from -99 to 99, not 0', + 'condition prone, modifier 4, amount: A whole number from -99 to 99, not 0', + ]); + expect(messages({ prone: { modifiers: 'x' } })).toEqual(['condition prone, modifier: Must be a list of modifiers']); + expect(messages({ prone: { modifiers: ['x', { target: 'str', amount: 1, note: 'n' }] } })).toEqual([ + 'condition prone, modifier 1: Must say a target and an amount', + 'condition prone, modifier 2, note: Only a target and an amount', + ]); + expect(messages({ prone: { modifiers: Array.from({ length: 11 }, () => ({ target: 'str', amount: 1 })) } })).toEqual([ + 'condition prone, modifier: At most 10 modifiers', + ]); + }); + + it('allows as many as a table needs, up to sixty with the standard ones', () => { + const own = (n) => Object.fromEntries(Array.from({ length: n }, (_, i) => [`c${i}`, { name: `C${i}` }])); + expect(check(own(LIMITS.conditions - STANDARD.length))).toEqual([]); + expect(messages(own(LIMITS.conditions - STANDARD.length + 1))).toEqual(['conditions: At most 60 conditions, the 10 standard ones included']); + // Edits to the standard ones don't count twice. + expect(check({ ...own(LIMITS.conditions - STANDARD.length), prone: { name: 'Down' } })).toEqual([]); + }); +}); + +describe('in a definition', () => { + const base = { + format: 1, name: 'Hearth', + stats: [{ id: 'abilities', label: 'ABILITIES', stats: [{ id: 'str', label: 'Strength' }] }], + derived: [{ id: 'save', label: 'Save', formula: '16 - @str' }], + }; + + it('is a section the checks know, its modifiers reaching the system\'s own stats and formulas', () => { + const def = { ...base, conditions: { prone: { modifiers: [{ target: 'str', amount: -1 }, { target: 'save', amount: 1 }] } } }; + expect(checkDefinition(def)).toEqual({ problems: [] }); + expect(checkDefinition({ ...base, conditions: { prone: { modifiers: [{ target: 'dex', amount: -1 }] } } }).problems).toEqual([ + { where: 'condition prone, modifier 1, target', message: 'Must be all rolls, or one of this system\'s stats or formulas' }, + ]); + }); +}); + +describe('uploading a condition\'s icon', () => { + const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); + const EDITOR = jwt.sign({ username: 'ghost', isTemporary: true }, 'test-secret'); + const PLAYER = jwt.sign({ username: 'vex', role: 'player' }, 'test-secret'); + const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '../uploads/condition_icons'); + const created = []; + let app; + beforeEach(async () => { + elevatedUsers.add('ghost'); + app = express(); + app.use(express.json()); + app.use('/api/systems', systemsRoute(await makeTestDb())); + }); + afterEach(() => { + elevatedUsers.delete('ghost'); + for (const f of created.splice(0)) { try { fs.unlinkSync(f); } catch { /* already gone */ } } + }); + const unique = (kind) => Buffer.from(`${kind} citynet-condition-test ${crypto.randomBytes(16).toString('hex')}`); + const upload = async (body, name, token = GM) => { + const res = await request(app).post('/api/systems/condition-icons').set({ Authorization: `Bearer ${token}` }).attach('icon', body, name); + if (res.status === 200) created.push(path.join(DIR, path.basename(res.body.icon))); + return res; + }; + + it('stores a PNG, WebP or SVG under its content hash, at an address a condition can name', async () => { + for (const ext of ['png', 'webp', 'svg']) { + const body = unique(ext); + const res = await upload(body, `hex.${ext}`); + const hash = crypto.createHash('sha256').update(body).digest('hex'); + expect(res.body, ext).toEqual({ icon: `/uploads/condition_icons/${hash}.${ext}` }); + expect(fs.readFileSync(path.join(DIR, `${hash}.${ext}`)).equals(body), ext).toBe(true); + expect(isIcon(res.body.icon), ext).toBe(true); + } + }); + + it('refuses another kind of file or one over a quarter of a megabyte, saying why', async () => { + expect(UPLOAD.condition_icon).toBe(256 * 1024); + expect((await upload(unique('gif'), 'hex.gif')).body).toMatchObject({ reason: 'UNSUPPORTED_FORMAT', allowed: ['.png', '.webp', '.svg'], maxBytes: UPLOAD.condition_icon }); + expect((await upload(Buffer.alloc(UPLOAD.condition_icon + 1, 1), 'huge.png')).body).toMatchObject({ reason: 'FILE_TOO_LARGE', error: '"huge.png" is over the 0.25MB limit.' }); + expect((await request(app).post('/api/systems/condition-icons').set({ Authorization: `Bearer ${GM}` })).body).toEqual({ error: 'No icon was sent' }); + }); + + it('is the main admin\'s alone, and stores nothing for anybody else', async () => { + const before = fs.existsSync(DIR) ? fs.readdirSync(DIR).length : 0; + expect((await upload(unique('png'), 'a.png', EDITOR)).status).toBe(403); + expect((await upload(unique('png'), 'a.png', PLAYER)).status).toBe(403); + expect((await request(app).post('/api/systems/condition-icons').attach('icon', unique('png'), 'a.png')).status).toBe(401); + expect(fs.existsSync(DIR) ? fs.readdirSync(DIR).length : 0).toBe(before); + }); +}); diff --git a/backend/middleware/uploadConstraints.js b/backend/middleware/uploadConstraints.js index 916277eb..f315d8c6 100644 --- a/backend/middleware/uploadConstraints.js +++ b/backend/middleware/uploadConstraints.js @@ -41,6 +41,8 @@ const LIMITS = { // A currency's icon, drawn at the size of a letter beside an amount (3c2c2). A quarter of a // megabyte is generous for that; more is a photo, not an icon. currency_icon: MB / 4, + // A condition's icon (4e1a), drawn the size of a letter in a chip and over a token: the same. + condition_icon: MB / 4, }; const asMb = (bytes) => `${(bytes / MB).toFixed(bytes < MB ? 2 : 1)}MB`; diff --git a/backend/routes/systems.js b/backend/routes/systems.js index 120111d8..1d7b6c1a 100644 --- a/backend/routes/systems.js +++ b/backend/routes/systems.js @@ -15,7 +15,7 @@ const { rollTier } = require('../systemBuilder/tierRolls'); const { tryHealth } = require('../systemBuilder/tryHealth'); const { exampleList, exampleDefinition, exampleKind, KINDS } = require('../systemBuilder/examples'); -/** What a currency's icon may be uploaded as (decided with the user, 2026-10-01). */ +/** What a currency's or condition's icon may be uploaded as (decided with the user, 2026-10-01). */ const ICON_EXT = new Set(['.png', '.webp', '.svg']); // Custom game systems: the builder's storage (see systemBuilder/store.js). @@ -29,15 +29,38 @@ module.exports = (db, io = null) => { // On every route rather than router.use, so the route walk in gm_route_auth.test.js sees them. const gm = [authenticate, requireMainAdmin]; - // In memory: an icon is capped small, and hashing a buffer is simplest (as building photos). - const iconsDir = path.join(__dirname, '../uploads/currency_icons'); - const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: LIMITS.currency_icon }, - // Recorded so a refusal for size can name the file - multer aborts before any handler. - fileFilter: (req, file, cb) => { req.uploadFilename = file.originalname; cb(null, true); }, - }); - const iconUploadErrors = uploadErrors({ allowed: [...ICON_EXT], maxBytes: LIMITS.currency_icon }); + /** + * An icon upload: a small PNG, WebP or SVG stored under its content hash in `folder`, so the same + * picture twice is one file, answered with its address. /uploads serves it with the sandbox + * headers (middleware/uploadHeaders.js), and the windows only ever draw it through , which + * is what makes SVG safe to take, as with battle maps. In memory: an icon is capped small, and + * hashing a buffer is simplest (as building photos). + */ + const iconUpload = (folder, maxBytes) => { + const dir = path.join(__dirname, '../uploads', folder); + const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: maxBytes }, + // Recorded so a refusal for size can name the file - multer aborts before any handler. + fileFilter: (req, file, cb) => { req.uploadFilename = file.originalname; cb(null, true); }, + }); + const store = (req, res) => { + if (!req.file) return res.status(400).json({ error: 'No icon was sent' }); + const ext = path.extname(req.file.originalname || '').toLowerCase(); + // The extension decides how it is served back: the type follows the name on disk. + if (!ICON_EXT.has(ext)) return rejectFormat(res, { file: req.file, allowed: [...ICON_EXT], maxBytes }); + const filename = crypto.createHash('sha256').update(req.file.buffer).digest('hex') + ext; + try { + fs.mkdirSync(dir, { recursive: true }); + const filepath = path.join(dir, filename); + if (!fs.existsSync(filepath)) fs.writeFileSync(filepath, req.file.buffer); + } catch (e) { + return res.status(500).json({ error: 'Could not store the icon.' }); + } + return res.json({ icon: `/uploads/${folder}/${filename}` }); + }; + return [upload.single('icon'), uploadErrors({ allowed: [...ICON_EXT], maxBytes }), store]; + }; /** The status a store error carries, or 500. */ const answer = (res, err, body) => { @@ -49,28 +72,10 @@ module.exports = (db, io = null) => { router.get('/', gm, (req, res) => store.listSystems(db, (err, systems) => answer(res, err, systems))); - /** - * Upload an icon for a currency (3c2c2): a small PNG, WebP or SVG, stored under its content - * hash, so the same picture twice is one file. Answers with its address, which a currency's - * `icon` then names (currencies.js). /uploads serves it with the sandbox headers - * (middleware/uploadHeaders.js), and the windows only ever draw it through , which is what - * makes SVG safe to take, as with battle maps. - */ - router.post('/currency-icons', gm, upload.single('icon'), iconUploadErrors, (req, res) => { - if (!req.file) return res.status(400).json({ error: 'No icon was sent' }); - const ext = path.extname(req.file.originalname || '').toLowerCase(); - // The extension decides how it is served back: the type follows the name on disk. - if (!ICON_EXT.has(ext)) return rejectFormat(res, { file: req.file, allowed: [...ICON_EXT], maxBytes: LIMITS.currency_icon }); - const filename = crypto.createHash('sha256').update(req.file.buffer).digest('hex') + ext; - try { - fs.mkdirSync(iconsDir, { recursive: true }); - const filepath = path.join(iconsDir, filename); - if (!fs.existsSync(filepath)) fs.writeFileSync(filepath, req.file.buffer); - } catch (e) { - return res.status(500).json({ error: 'Could not store the icon.' }); - } - res.json({ icon: `/uploads/currency_icons/${filename}` }); - }); + // Upload an icon for a currency (3c2c2), which a currency's `icon` then names (currencies.js), + // or for a condition (4e1a), which a condition's `icon` names (conditions.js). + router.post('/currency-icons', gm, ...iconUpload('currency_icons', LIMITS.currency_icon)); + router.post('/condition-icons', gm, ...iconUpload('condition_icons', LIMITS.condition_icon)); // The built-in examples (4d1) and genre starters (4d3, ?kind=starter): what the library lists, // and one whole, to read. Copying either is POST / with `example`. diff --git a/backend/systemBuilder/conditions.js b/backend/systemBuilder/conditions.js new file mode 100644 index 00000000..1663ed3f --- /dev/null +++ b/backend/systemBuilder/conditions.js @@ -0,0 +1,158 @@ +// A system's conditions (4e1a; approved mockup docs/mockups/builder-conditions.html, 2026-10-09): +// what can happen to a character besides losing health. +// +// conditions: { +// blinded: { on: false }, // a standard one, turned off +// poisoned: { ends: 'rounds', rounds: 3, +// modifiers: [{ target: 'all_rolls', amount: -1 }] }, // a standard one, edited +// glitching: { name: 'Glitching', short: 'GLITCH', icon: '/uploads/condition_icons/.png', +// description: 'Chrome misfiring.' }, // one of the system's own +// } +// +// Every system starts with the standard set below; a definition stores only what it changes, as +// WORDS does: a standard condition left alone is not written down. Any other id is the system's +// own condition and needs a name. Decided with the user the same day: +// - conditions for every system, as many as a table needs (up to LIMITS.conditions in all); +// - an icon is one of ICONS, drawn for CITY_NET in the theme's colors, or an uploaded PNG, WebP or +// SVG (POST /api/systems/condition-icons), kept in its own colors as currency icons are; +// - a condition ends when removed, or after a number of rounds (refresh events come with 4f); +// - modifiers name a stat, a formula or all rolls, and are recorded and shown now; they reach +// rolls once a system's rolls are built (Phase 6). +// +// Pure: the definition checks and the running game both read it. + +const NAME = /^[a-z][a-z0-9_]{0,39}$/; +const LIMITS = { conditions: 60, name: 30, short: 8, description: 300, rounds: 99, modifiers: 10, amount: 99 }; + +/** The icons drawn for CITY_NET (frontend/src/sheets/conditionIcons.ts holds the drawings). */ +const ICONS = [ + 'blind', 'drop', 'skull', 'down', 'star', 'hand', 'ghost', 'zzz', 'chain', 'battery', 'flame', 'bolt', + 'spiral', 'snow', 'bio', 'closed', 'signal', 'mask', 'anchor', 'target', 'heart', 'shield', 'music', 'pill', +]; +const UPLOADED_ICON = /^\/uploads\/condition_icons\/[0-9a-f]{64}\.(png|webp|svg)$/; +const isIcon = (icon) => typeof icon === 'string' && (ICONS.includes(icon) || UPLOADED_ICON.test(icon)); + +/** How a condition ends: when someone takes it off, or after a number of rounds. */ +const ENDS = ['removed', 'rounds']; +/** A modifier's target that is no single number: every roll the character makes. */ +const ALL_ROLLS = 'all_rolls'; + +/** The standard set every system starts with, in this order. Names and words are CITY_NET's own. */ +const STANDARD = [ + { id: 'blinded', name: 'Blinded', short: 'BLIND', icon: 'blind', description: 'Can\'t see.' }, + { id: 'bleeding', name: 'Bleeding', short: 'BLEED', icon: 'drop', description: 'Losing blood until treated.' }, + { id: 'poisoned', name: 'Poisoned', short: 'POISON', icon: 'skull', description: 'Sickened by a toxin.' }, + { id: 'prone', name: 'Prone', short: 'PRONE', icon: 'down', description: 'On the ground.' }, + { id: 'stunned', name: 'Stunned', short: 'STUN', icon: 'star', description: 'Reeling, unable to act.' }, + { id: 'grappled', name: 'Grappled', short: 'GRAB', icon: 'hand', description: 'Held in place by someone.' }, + { id: 'frightened', name: 'Frightened', short: 'FEAR', icon: 'ghost', description: 'Shaken by fear.' }, + { id: 'unconscious', name: 'Unconscious', short: 'OUT', icon: 'zzz', description: 'Knocked out and unaware.' }, + { id: 'restrained', name: 'Restrained', short: 'BOUND', icon: 'chain', description: 'Tied, cuffed or pinned.' }, + { id: 'exhausted', name: 'Exhausted', short: 'TIRED', icon: 'battery', description: 'Running on empty.' }, +]; +const STANDARD_IDS = new Set(STANDARD.map((c) => c.id)); + +const STANDARD_KEYS = new Set(['on', 'name', 'short', 'icon', 'description', 'ends', 'rounds', 'modifiers']); +const OWN_KEYS = new Set(['name', 'short', 'icon', 'description', 'ends', 'rounds', 'modifiers']); + +const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); +const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); + +const text = (value, where, max, problems, { required = false } = {}) => { + if (value === undefined) { if (required) problems.push({ where, message: 'Required' }); return; } + if (typeof value !== 'string' || (required && !value.trim())) { problems.push({ where, message: required ? 'Cannot be blank' : 'Must be text' }); return; } + if (value.length > max) problems.push({ where, message: `Longer than ${max} characters` }); +}; + +const checkModifiers = (modifiers, where, targets, problems) => { + if (modifiers === undefined) return; + if (!Array.isArray(modifiers)) { problems.push({ where, message: 'Must be a list of modifiers' }); return; } + if (modifiers.length > LIMITS.modifiers) problems.push({ where, message: `At most ${LIMITS.modifiers} modifiers` }); + modifiers.slice(0, LIMITS.modifiers).forEach((m, i) => { + const mw = `${where} ${i + 1}`; + if (!isPlainObject(m)) { problems.push({ where: mw, message: 'Must say a target and an amount' }); return; } + for (const key of Object.keys(m)) if (key !== 'target' && key !== 'amount') problems.push({ where: `${mw}, ${key}`, message: 'Only a target and an amount' }); + if (m.target !== ALL_ROLLS && !(typeof m.target === 'string' && targets.has(m.target))) { + problems.push({ where: `${mw}, target`, message: 'Must be all rolls, or one of this system\'s stats or formulas' }); + } + if (!Number.isInteger(m.amount) || m.amount === 0 || Math.abs(m.amount) > LIMITS.amount) { + problems.push({ where: `${mw}, amount`, message: `A whole number from -${LIMITS.amount} to ${LIMITS.amount}, not 0` }); + } + }); +}; + +/** + * Problems with a definition's `conditions`, pushed onto `problems`. `targets` are the ids a + * modifier may name: the system's stats and formulas. + */ +const checkConditions = (conditions, targets, problems) => { + if (conditions === undefined) return; + if (!isPlainObject(conditions)) { problems.push({ where: 'conditions', message: 'Must be a set of conditions' }); return; } + const own = Object.keys(conditions).filter((id) => !STANDARD_IDS.has(id)); + if (STANDARD.length + own.length > LIMITS.conditions) { + problems.push({ where: 'conditions', message: `At most ${LIMITS.conditions} conditions, the ${STANDARD.length} standard ones included` }); + } + for (const [id, c] of Object.entries(conditions)) { + const where = `condition ${id}`; + const standard = STANDARD_IDS.has(id); + if (!NAME.test(id)) { problems.push({ where, message: 'Ids use lowercase letters, digits and _, starting with a letter' }); continue; } + if (!isPlainObject(c)) { problems.push({ where, message: 'Must be a condition' }); continue; } + for (const key of Object.keys(c)) { + if ((standard ? STANDARD_KEYS : OWN_KEYS).has(key)) continue; + problems.push({ where: `${where}, ${key}`, message: key === 'on' ? 'Only a standard condition is turned off; delete one of the system\'s own instead' : 'Not part of a condition' }); + } + if (has(c, 'on') && typeof c.on !== 'boolean') problems.push({ where: `${where}, on`, message: 'Must say on: true or on: false' }); + text(c.name, `${where}, name`, LIMITS.name, problems, { required: !standard }); + text(c.short, `${where}, short`, LIMITS.short, problems); + text(c.description, `${where}, description`, LIMITS.description, problems); + if (c.icon !== undefined && !isIcon(c.icon)) problems.push({ where: `${where}, icon`, message: 'Must be one of the drawn icons or an uploaded one' }); + if (c.ends !== undefined && !ENDS.includes(c.ends)) problems.push({ where: `${where}, ends`, message: 'Ends when removed, or after rounds' }); + if (c.ends === 'rounds') { + if (!Number.isInteger(c.rounds) || c.rounds < 1 || c.rounds > LIMITS.rounds) { + problems.push({ where: `${where}, rounds`, message: `A whole number from 1 to ${LIMITS.rounds}` }); + } + } else if (c.rounds !== undefined) { + problems.push({ where: `${where}, rounds`, message: 'Only a condition that ends after rounds has rounds' }); + } + checkModifiers(c.modifiers, `${where}, modifier`, targets, problems); + } +}; + +/** A label for a chip from a name: its first word in capitals, cut to fit. */ +const shortFrom = (name) => String(name).trim().split(/\s+/)[0].toUpperCase().slice(0, LIMITS.short); + +/** + * The conditions a system offers, each whole: the standard set with the system's edits (those + * it turned off left out), then its own, in the order written. What the running game and the + * builder read; the stored definition keeps only the changes. + */ +const conditionsOf = (definition) => { + const stored = definition && isPlainObject(definition.conditions) ? definition.conditions : {}; + const whole = (base, c, standard) => { + const ends = ENDS.includes(c.ends) ? c.ends : 'removed'; + return { + id: base.id, + name: typeof c.name === 'string' && c.name.trim() ? c.name.trim() : base.name, + short: typeof c.short === 'string' && c.short.trim() ? c.short.trim().toUpperCase() : (base.short || shortFrom(c.name || base.name)), + icon: isIcon(c.icon) ? c.icon : (base.icon || 'target'), + description: typeof c.description === 'string' ? c.description : (base.description || ''), + ends, + ...(ends === 'rounds' && Number.isInteger(c.rounds) ? { rounds: c.rounds } : {}), + modifiers: Array.isArray(c.modifiers) ? c.modifiers.filter((m) => isPlainObject(m)).map((m) => ({ target: m.target, amount: m.amount })) : [], + standard, + }; + }; + const out = []; + for (const base of STANDARD) { + const c = isPlainObject(stored[base.id]) ? stored[base.id] : {}; + if (c.on === false) continue; + out.push(whole(base, c, true)); + } + for (const [id, c] of Object.entries(stored)) { + if (STANDARD_IDS.has(id) || !NAME.test(id) || !isPlainObject(c) || typeof c.name !== 'string' || !c.name.trim()) continue; + out.push(whole({ id, name: c.name.trim() }, c, false)); + } + return out.slice(0, LIMITS.conditions); +}; + +module.exports = { STANDARD, ICONS, ENDS, ALL_ROLLS, LIMITS, isIcon, checkConditions, conditionsOf }; diff --git a/backend/systemBuilder/definition.js b/backend/systemBuilder/definition.js index 38f33147..8a1df5b2 100644 --- a/backend/systemBuilder/definition.js +++ b/backend/systemBuilder/definition.js @@ -21,6 +21,7 @@ // sheet: { tabs, header, sections }, // the character sheet (sheet.js) // npc: { sheet, tiers }, // NPC layout and power tiers (npc.js) // core: { health, advancement, dice, distance }, // the setup questions (core.js) +// conditions: { blinded: { on: false }, glitching: { name: 'Glitching', ... } }, // (conditions.js) // } // // Problems come in two weights. A **fatal** one means the document cannot be stored at all: @@ -45,6 +46,7 @@ const { PARTS, partOn } = require('./parts'); const { checkBuildings } = require('./buildings'); const { checkCurrencies } = require('./currencies'); const { checkBank } = require('./bank'); +const { checkConditions } = require('./conditions'); const FORMAT = 1; @@ -60,7 +62,7 @@ const LIMITS = { word: 40, }; -const SECTIONS = new Set(['format', 'name', 'description', 'author', 'license', 'words', 'parts', 'buildings', 'currencies', 'bank', 'stats', 'samples', 'lookups', 'derived', 'sheet', 'npc', 'core']); +const SECTIONS = new Set(['format', 'name', 'description', 'author', 'license', 'words', 'parts', 'buildings', 'currencies', 'bank', 'stats', 'samples', 'lookups', 'derived', 'sheet', 'npc', 'core', 'conditions']); const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v); const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); @@ -165,6 +167,7 @@ const checkDefinition = (definition) => { checkSheet(definition.sheet, derivedIds, problems); checkCore(definition.core, derivedIds, problems); checkNpc(definition, derivedIds, problems); + checkConditions(definition.conditions, new Set([...statIdsOf(definition), ...derivedIds]), problems); return { problems }; };