From df50afa479cdfd62bb1492bfe63fa5529572257c Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:01:31 -0500 Subject: [PATCH] =?UTF-8?q?feat(world):=20item=20schema=20=E2=80=94=20read?= =?UTF-8?q?able,=20lightable,=20lighter,=20lighterUses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional fields used by the new read/light/extinguish dispatcher branches. Loader updates and dispatcher logic follow. Co-Authored-By: Claude Opus 4.7 --- src/world/loader.ts | 2 +- src/world/schema.test.ts | 24 ++++++++++++++++++++++++ src/world/schema.ts | 6 +++++- src/world/types.ts | 24 ++++++++++++++++++++---- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/world/loader.ts b/src/world/loader.ts index 75880bb..d3fc545 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -150,7 +150,7 @@ export function parseItem(raw: string, sourcePath: string): Item { export interface ParsedEnding { id: 'true' | 'wrong' | 'bad' - ending: { whenFlags: Record; narration: string } + ending: { whenFlags: Record; narration: string } } export function parseEnding(raw: string, _sourcePath: string): ParsedEnding { diff --git a/src/world/schema.test.ts b/src/world/schema.test.ts index f3f581a..632b9f4 100644 --- a/src/world/schema.test.ts +++ b/src/world/schema.test.ts @@ -79,3 +79,27 @@ describe('encounterFrontmatterSchema', () => { expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow() }) }) + +describe('itemFrontmatterSchema — bible additions', () => { + it('accepts readable + lighter fields', () => { + const data = { + id: 'matches', + names: ['matches', 'matchbook'], + short: 'a matchbook', + takeable: true, + lighter: true, + lighterUses: 4, + } + expect(() => itemFrontmatterSchema.parse(data)).not.toThrow() + }) + + it('accepts lightable on its own', () => { + const data = { id: 'lamp', names: ['lamp'], short: 'a lamp', takeable: true, lightable: true } + expect(() => itemFrontmatterSchema.parse(data)).not.toThrow() + }) + + it('rejects negative lighterUses', () => { + const data = { id: 'matches', names: ['matches'], short: 'matches', takeable: true, lighter: true, lighterUses: -1 } + expect(() => itemFrontmatterSchema.parse(data)).toThrow() + }) +}) diff --git a/src/world/schema.ts b/src/world/schema.ts index f93f343..2d19aeb 100644 --- a/src/world/schema.ts +++ b/src/world/schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -const stateValueSchema = z.union([z.string(), z.boolean(), z.number()]) +const stateValueSchema = z.union([z.string(), z.boolean(), z.number(), z.array(z.string())]) const stateRecordSchema = z.record(z.string(), stateValueSchema) export const roomFrontmatterSchema = z.object({ @@ -37,6 +37,10 @@ export const itemFrontmatterSchema = z.object({ short: z.string().min(1), takeable: z.boolean(), initialState: stateRecordSchema.default({}), + readable: z.boolean().optional(), + lightable: z.boolean().optional(), + lighter: z.boolean().optional(), + lighterUses: z.number().int().nonnegative().optional(), }) export type ItemFrontmatter = z.infer diff --git a/src/world/types.ts b/src/world/types.ts index a1449b5..e35c301 100644 --- a/src/world/types.ts +++ b/src/world/types.ts @@ -37,9 +37,25 @@ export interface Item { /** Long description shown when examined. */ long: string /** Initial per-instance state (e.g. `{ lit: false }`). */ - initialState: Record + initialState: Record /** True if the player can pick it up. */ takeable: boolean + /** True if `read X` should narrate the item's `## read` section. */ + readable?: boolean + /** True if `light X` / `extinguish X` apply; toggles state.lit. */ + lightable?: boolean + /** True if this item can light other items. */ + lighter?: boolean + /** Optional remaining-charges counter; absent means unlimited. */ + lighterUses?: number + /** Prose returned by `read X`. Required iff readable is true. */ + readableText?: string + /** Prose narrated when `light X` succeeds. Falls back to "It catches." */ + litText?: string + /** Prose narrated when `extinguish X` succeeds. Falls back to "The flame dies." */ + extinguishedText?: string + /** Prose narrated when this item's lighterUses reaches 0. Falls back to "It is spent." */ + lighterEmptyText?: string } export interface EncounterPhaseDef { @@ -83,8 +99,8 @@ export interface World { encounters: Record /** Story flag definitions and the endings they unlock. */ endings: { - true: { whenFlags: Record; narration: string } - wrong: { whenFlags: Record; narration: string } - bad: { whenFlags: Record; narration: string } + true: { whenFlags: Record; narration: string } + wrong: { whenFlags: Record; narration: string } + bad: { whenFlags: Record; narration: string } } }