diff --git a/README.md b/README.md index 28e582f..1db0868 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,28 @@ npm run dev # local dev server npm run build # type-check + production build ``` +## Make Your Own Game + +Halfstreet is currently meant to be forked as a complete Astro app, not consumed +as a separate engine package. To make a new story, replace the markdown vault in +`src/world/` and keep the TypeScript runtime in place. + +Start with: + +- `src/world/game.md` for the title, starting room, starting inventory, ending + priority, opening art, help text, and end text. +- `src/world/parser.md` for command vocabulary and aliases. +- `src/world/rooms/`, `src/world/items/`, `src/world/encounters/`, and + `src/world/endings/` for story content. +- `src/world/mechanics/` and `src/world/actions/` for configurable rules and + interactions. +- `src/world/ui.md` for page metadata, footer links, and UI feature switches. +- `src/world/templates/` for starter files. + +Run `npm test` after changing world files. The loader validates wikilinks, +required sections, frontmatter shape, and references between rooms, items, +encounters, endings, mechanics, and actions. + ## Releases The footer build number comes from Woodpecker's pipeline number and increments on each CI build. @@ -42,7 +64,7 @@ Each release script updates `package.json` and `package-lock.json`, creates a re - `src/engine/` — parser, dispatcher, encounter logic - `src/ui/` — terminal renderer, theme, chips -- `src/world/` — markdown content (rooms, items, encounters, endings) +- `src/world/` — Obsidian-friendly authoring vault - `src/pages/index.astro` — entry page ## Design docs diff --git a/package-lock.json b/package-lock.json index 43e263b..1b249bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "halfstreet", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "halfstreet", - "version": "0.0.1", + "version": "0.1.0", "license": "GPL-3.0-or-later", "dependencies": { "astro": "^6.1.9", diff --git a/package.json b/package.json index b6d770a..83da3f8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "halfstreet", "type": "module", - "version": "0.0.1", + "version": "0.1.0", "license": "GPL-3.0-or-later", "engines": { "node": ">=22.12.0" diff --git a/src/engine/dispatcher.test.ts b/src/engine/dispatcher.test.ts index 82c0521..444297b 100644 --- a/src/engine/dispatcher.test.ts +++ b/src/engine/dispatcher.test.ts @@ -326,6 +326,79 @@ describe('light status', () => { maxTurns: 6, }) }) + + it('uses the configured light meter length and state keys', () => { + const lightWorld: World = { + ...world, + mechanics: { + light: { + enabled: true, + handler: 'light', + maxTurns: 3, + burnOn: ['wait'], + stateKeys: { lit: 'isLit', burn: 'fuel' }, + ui: { meter: true, icon: 'candle' }, + }, + }, + items: { + ...world.items, + torch: { + id: 'torch', + names: ['torch', 'lamp'], + short: 'an oil lamp', + long: 'An iron oil lamp, unlit.', + initialState: { isLit: false }, + takeable: true, + lightable: true, + }, + }, + } + const state: GameState = { + ...initialStateFor(lightWorld), + inventory: [{ id: 'torch', state: { isLit: true, fuel: 2 } }], + } + + expect(getLightStatus(state, lightWorld)).toEqual({ + itemId: 'torch', + lit: true, + turnsLeft: 2, + maxTurns: 3, + }) + }) + + it('hides the meter when the light mechanic is disabled', () => { + const lightWorld: World = { + ...world, + mechanics: { + light: { + enabled: false, + handler: 'light', + maxTurns: 6, + burnOn: ['move', 'wait'], + stateKeys: { lit: 'lit', burn: 'burn' }, + ui: { meter: true, icon: 'candle' }, + }, + }, + items: { + ...world.items, + torch: { + id: 'torch', + names: ['torch', 'lamp'], + short: 'an oil lamp', + long: 'An iron oil lamp, unlit.', + initialState: { lit: false }, + takeable: true, + lightable: true, + }, + }, + } + const state: GameState = { + ...initialStateFor(lightWorld), + inventory: [{ id: 'torch', state: { lit: true, burn: 6 } }], + } + + expect(getLightStatus(state, lightWorld)).toBeNull() + }) }) describe('ambiguous → disambiguation flow', () => { @@ -553,6 +626,100 @@ describe('light/extinguish verbs (implicit lighter)', () => { expect(result.state.location).toBe('r2') }) + it('uses configured maxTurns when lighting and burning down', () => { + const baseWorld = w() + const world: World = { + ...baseWorld, + mechanics: { + light: { + enabled: true, + handler: 'light', + maxTurns: 3, + burnOn: ['wait'], + stateKeys: { lit: 'lit', burn: 'burn' }, + ui: { meter: true, icon: 'candle' }, + messages: { flameDies: 'The configured light dies.' }, + }, + }, + items: { + ...baseWorld.items, + lamp: { + id: 'lamp', + names: ['lamp'], + short: 'an oil lamp', + long: '.', + initialState: { lit: false }, + takeable: true, + lightable: true, + litText: 'The wick catches.', + }, + }, + } + let state = initialStateFor(world) + state = { ...state, inventory: [ + { id: 'lamp', state: { lit: false } }, + { id: 'matches', state: { uses: 2 } }, + ] } + + const lit = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world) + expect(lit.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(3) + + const first = dispatch(lit.state, { kind: 'verb-only', verb: 'wait' }, world) + expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(2) + const second = dispatch(first.state, { kind: 'verb-only', verb: 'wait' }, world) + expect(second.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(1) + const third = dispatch(second.state, { kind: 'verb-only', verb: 'wait' }, world) + expect(third.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false) + expect(third.appended.map((l) => l.text)).toContain('The configured light dies.') + }) + + it('does not burn down on movement when move is not configured', () => { + const world: World = { + ...w(), + mechanics: { + light: { + enabled: true, + handler: 'light', + maxTurns: 3, + burnOn: ['wait'], + stateKeys: { lit: 'lit', burn: 'burn' }, + ui: { meter: true, icon: 'candle' }, + }, + }, + rooms: { + r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: { n: 'r2' }, items: [] }, + r2: { id: 'r2', title: '[ R2 ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] }, + }, + } + let state = initialStateFor(world) + state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 3 } }] } + + const result = dispatch(state, { kind: 'go', direction: 'n' }, world) + expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(3) + }) + + it('disabling the light mechanic removes burn-down behavior', () => { + const world: World = { + ...w(), + mechanics: { + light: { + enabled: false, + handler: 'light', + maxTurns: 6, + burnOn: ['move', 'wait'], + stateKeys: { lit: 'lit', burn: 'burn' }, + ui: { meter: true, icon: 'candle' }, + }, + }, + } + let state = initialStateFor(world) + state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 1 } }] } + + const result = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world) + expect(result.state.inventory.find((i) => i.id === 'lamp')?.state).toEqual({ lit: true, burn: 1 }) + expect(result.appended.map((l) => l.text)).not.toContain('The flame dies.') + }) + it('extinguishes a lit lamp', () => { const world = w() let state = initialStateFor(world) @@ -633,6 +800,21 @@ describe('use verb routing', () => { letter: { id: 'letter', names: ['letter'], short: 'a letter', long: '.', initialState: {}, takeable: true, readable: true, readableText: 'Read me.' }, 'broken-cigarette': { id: 'broken-cigarette', names: ['cigarette', 'broken cigarette'], short: 'a broken cigarette', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The end glows once, then steadies. The smoke is bitter.' }, }, + actions: { + 'burn-letter': { + id: 'burn-letter', + verbs: ['use'], + requires: { allVisibleOrHeld: ['letter', 'matches'] }, + consumes: { inventory: ['letter'] }, + decrements: { item: 'matches', stateKey: 'uses' }, + setsFlags: { letterBurned: true }, + messages: { + success: 'The letter catches at one corner. In a few breaths it is ash.', + spent: 'The matchbook is empty.', + missingRequired: "You don't see the letter here.", + }, + }, + }, encounters: {}, endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } }, } @@ -695,6 +877,77 @@ describe('use verb routing', () => { }) }) +describe('handler-backed drink action', () => { + function w(): World { + return { + startingRoom: 'r', + startingInventory: ['whiskey'], + rooms: { + r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] }, + 'drunk-start': { id: 'drunk-start', title: '[ Drunk Start ]', descriptions: { firstVisit: 'The hall tips.', revisit: 'The hall tips again.', examined: '.' }, exits: { n: 'drunk-next' }, items: [] }, + 'drunk-next': { id: 'drunk-next', title: '[ Drunk Next ]', descriptions: { firstVisit: 'The room doubles.', revisit: 'The room doubles again.', examined: '.' }, exits: { s: 'drunk-start' }, items: [] }, + vestibule: { id: 'vestibule', title: '[ Vestibule ]', descriptions: { firstVisit: '.', revisit: 'You wake somewhere else.', examined: '.' }, exits: {}, items: [] }, + pantry: { id: 'pantry', title: '[ Pantry ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: ['whiskey'] }, + }, + items: { + whiskey: { id: 'whiskey', names: ['whiskey'], short: 'a bottle of whiskey', long: '.', initialState: {}, takeable: true }, + }, + actions: { + 'drink-whiskey': { + id: 'drink-whiskey', + verbs: ['drink'], + handler: 'drunk-transition', + requires: { allHeld: ['whiskey'] }, + consumes: { inventory: ['whiskey'] }, + drunkTransition: { + destinationRoom: 'drunk-start', + maxMoves: 2, + wakeRoom: 'vestibule', + resetRoom: 'pantry', + }, + messages: { + success: 'Custom drink text.', + missingRequired: 'Hold it first.', + tooManyMovesPassOut: 'Custom pass out.', + reset: 'Custom reset.', + }, + }, + }, + encounters: {}, + endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } }, + } + } + + it('uses markdown action config for destination, move cap, wake room, and reset room', () => { + const world = w() + let state = initialStateFor(world) + let result = dispatch(state, { kind: 'verb-target', verb: 'drink', target: { canonical: 'whiskey', raw: 'whiskey' } }, world) + + expect(result.state.location).toBe('drunk-start') + expect(result.state.inventory.find((i) => i.id === 'whiskey')).toBeUndefined() + expect(result.appended.map((l) => l.text)).toContain('Custom drink text.') + + state = { + ...result.state, + roomState: { + ...result.state.roomState, + pantry: { takenItems: ['whiskey'], droppedItems: ['whiskey'] }, + }, + } + result = dispatch(state, { kind: 'go', direction: 'n' }, world) + expect(result.state.location).toBe('drunk-next') + expect(result.state.flags['drunkMoves']).toBe(1) + + result = dispatch(result.state, { kind: 'go', direction: 's' }, world) + expect(result.state.location).toBe('vestibule') + expect(result.state.flags['drunk']).toBe(false) + expect(result.state.roomState['pantry']?.['takenItems']).toEqual([]) + expect(result.state.roomState['pantry']?.['droppedItems']).toEqual([]) + expect(result.appended.map((l) => l.text)).toContain('Custom pass out.') + expect(result.appended.map((l) => l.text)).toContain('Custom reset.') + }) +}) + describe('ending detection', () => { function makeWorld(): World { return { diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index 304d36b..e60e35a 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -1,10 +1,47 @@ -import type { World } from '../world/types' -import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine } from './types' -import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types' +import { DEFAULT_WORLD_MESSAGES, type DeclarativeAction, type LightMechanicMessageKey, type World, type WorldMessageKey } from '../world/types' +import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine, ResolveLevel } from './types' +import { SCHEMA_VERSION, TRANSCRIPT_CAP, RESOLVE_LEVELS } from './types' import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters' -export const LIGHT_TURNS_MAX = 6 -const DRUNK_TURNS_MAX = 20 +type ActiveLightMechanic = NonNullable['light']> +type ActiveResolveMechanic = NonNullable['resolve']> + +const DEFAULT_LIGHT_MECHANIC: ActiveLightMechanic = { + enabled: true, + handler: 'light', + maxTurns: 6, + burnOn: ['move', 'wait'], + stateKeys: { lit: 'lit', burn: 'burn' }, + ui: { meter: true, icon: 'candle' }, + messages: {}, +} +const DEFAULT_RESOLVE_MECHANIC: ActiveResolveMechanic = { + enabled: true, + handler: 'resolve', + ladder: RESOLVE_LEVELS, + wrongVerbCost: 1, + safeRooms: { recoverySteps: 1 }, + failure: { retreatAt: 'returning', afterRetreat: 'shaken' }, +} +const DEFAULT_DRUNK_ACTION: DeclarativeAction = { + id: 'drink-whiskey', + verbs: ['drink'], + handler: 'drunk-transition', + requires: { allHeld: ['whiskey'] }, + consumes: { inventory: ['whiskey'] }, + drunkTransition: { + destinationRoom: 'drunk-hall', + maxMoves: 20, + wakeRoom: 'foyer', + resetRoom: 'kitchen', + }, + messages: { + success: 'You drink from the bottle. It tastes of smoke, sugar, and rainwater left too long in a pipe.', + secretFoundPassOut: 'The faceless man steps backward into the dark. The floor rises under you, or you fall toward it.', + tooManyMovesPassOut: 'The rooms keep turning until they become one room. Then even that room is gone.', + reset: 'The bottle is not with you. Somewhere in the kitchen, it is half full again.', + }, +} export interface LightStatus { itemId: string @@ -13,13 +50,34 @@ export interface LightStatus { maxTurns: number } -const HALFSTREET_ASCII = String.raw` - _ _ _ __ ____ _ _ -| | | | __ _| |/ _| / ___|| |_ _ __ ___ ___| |_ -| |_| |/ _\` | | |_ \___ \| __| '__/ _ \/ _ \ __| -| _ | (_| | | _| ___) | |_| | | __/ __/ |_ -|_| |_|\__,_|_|_| |____/ \__|_| \___|\___|\__| -`.trim() +function message(world: World, key: WorldMessageKey): string { + return world.messages?.[key] ?? DEFAULT_WORLD_MESSAGES[key] +} + +function lightMechanic(world: World): ActiveLightMechanic { + return world.mechanics?.light ?? DEFAULT_LIGHT_MECHANIC +} + +function resolveMechanic(world: World): ActiveResolveMechanic { + return world.mechanics?.resolve ?? DEFAULT_RESOLVE_MECHANIC +} + +function drunkAction(world: World): DeclarativeAction { + return Object.values(world.actions ?? {}).find((action) => action.handler === 'drunk-transition') ?? DEFAULT_DRUNK_ACTION +} + +function recoverResolve(level: ResolveLevel, world: World): ResolveLevel { + const mechanic = resolveMechanic(world) + if (!mechanic.enabled || mechanic.safeRooms.recoverySteps === 0) return level + const idx = mechanic.ladder.indexOf(level) + if (idx <= 0) return level + return mechanic.ladder[Math.max(0, idx - mechanic.safeRooms.recoverySteps)] ?? level +} + +function lightMessage(world: World, key: LightMechanicMessageKey, fallback: WorldMessageKey): string { + const mechanic = lightMechanic(world) + return mechanic?.messages?.[key] ?? message(world, fallback) +} export function initialStateFor(world: World): GameState { const startingRoom = world.rooms[world.startingRoom] @@ -32,13 +90,14 @@ export function initialStateFor(world: World): GameState { }) const opening: TranscriptLine[] = [ - { kind: 'system', text: HALFSTREET_ASCII }, + ...(world.game?.openingArt ? [{ kind: 'system' as const, text: world.game.openingArt }] : []), { kind: 'system', text: startingRoom.title }, { kind: 'narration', text: startingRoom.descriptions.firstVisit }, ] return { schemaVersion: SCHEMA_VERSION, + transcriptCap: world.game?.transcriptCap, location: world.startingRoom, inventory, roomState: { [world.startingRoom]: { visited: true } }, @@ -54,17 +113,20 @@ export function initialStateFor(world: World): GameState { } export function getLightStatus(state: GameState, world: World): LightStatus | null { + const mechanic = lightMechanic(world) + if (!mechanic?.enabled || mechanic.ui?.meter === false) return null + let fallback: LightStatus | null = null for (const inst of state.inventory) { const def = world.items[inst.id] if (!def?.lightable) continue - const lit = inst.state['lit'] === true - const turnsLeft = lit ? getLightTurnsLeft(inst) : 0 + const lit = inst.state[mechanic.stateKeys.lit] === true + const turnsLeft = lit ? getLightTurnsLeft(inst, world) : 0 const status = { itemId: inst.id, lit, turnsLeft, - maxTurns: LIGHT_TURNS_MAX, + maxTurns: mechanic.maxTurns, } if (lit) return status fallback = fallback ?? status @@ -74,7 +136,8 @@ export function getLightStatus(state: GameState, world: World): LightStatus | nu function append(state: GameState, lines: TranscriptLine[]): GameState { const transcript = [...state.transcript, ...lines] - return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) } + const cap = state.transcriptCap ?? TRANSCRIPT_CAP + return { ...state, transcript: transcript.slice(-cap) } } export function getItemsInRoom(state: GameState, world: World, roomId: string): string[] { @@ -94,11 +157,10 @@ function setRoomFlag(state: GameState, roomId: string, key: string, value: strin } } -const ENDING_PRIORITY = ['mercy', 'true', 'replacement', 'bad', 'wrong'] as const - function evaluateEndings(state: GameState, world: World): GameState | null { if (state.endedWith) return null - for (const id of ENDING_PRIORITY) { + const priority = world.endingPriority ?? Object.keys(world.endings) + for (const id of priority) { const ending = world.endings[id] if (!ending) continue const flags = ending.whenFlags @@ -145,11 +207,11 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World, if (command.kind === 'confirmation') { const pending = state.pendingConfirmation if (!pending) { - return narrate(state, [{ kind: 'narration', text: 'Nothing to confirm.' }]) + return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-to-confirm') }]) } const cleared: GameState = { ...state, pendingConfirmation: null } if (!command.confirmed) { - return narrate(cleared, [{ kind: 'narration', text: 'Cancelled.' }]) + return narrate(cleared, [{ kind: 'narration', text: message(world, 'cancelled') }]) } return dispatch(cleared, pending.command, world, true) } @@ -160,7 +222,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World, // Once the game has ended, only restart/undo (handled by the UI) can clear state. if (state.endedWith) { - return narrate(state, [{ kind: 'narration', text: 'The story has ended. Type `restart` or `undo`.' }]) + return narrate(state, [{ kind: 'narration', text: world.game?.endedText ?? 'The story has ended. Type `restart` or `undo`.' }]) } if (!confirmed && isCriticalCommand(command)) { @@ -173,7 +235,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World, if (command.kind === 'disambiguation') { const pending = state.pendingDisambiguation if (!pending) { - return narrate(state, [{ kind: 'narration', text: 'Nothing to choose between.' }]) + return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-to-choose') }]) } const cleared: GameState = { ...state, pendingDisambiguation: null } return dispatch( @@ -186,9 +248,9 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World, if (command.kind === 'unknown') { const text = - command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.' - : command.reason === 'unknown-noun' ? 'You don\'t see anything like that here.' - : 'You hesitate.' + command.reason === 'unknown-verb' ? message(world, 'unknown-verb') + : command.reason === 'unknown-noun' ? message(world, 'unknown-noun') + : message(world, 'malformed') return narrate(state, [{ kind: 'narration', text }]) } @@ -222,7 +284,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World, if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world) if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world) if (command.verb === 'wait') return withEndingCheck(handleWait(state, world), world) - if (command.verb === 'listen') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'You listen. The house listens back.' }]), world) + if (command.verb === 'listen') return withEndingCheck(narrate(state, [{ kind: 'narration', text: message(world, 'listen') }]), world) } if (command.kind === 'verb-target') { @@ -242,9 +304,9 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World, if (command.verb === 'use') { const target = world.items[command.target.canonical] if (target?.lighter && !target.lightable) { - return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: 'Use match with what?' }]), world) + return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: message(world, 'use-lighter-with-what') }]), world) } - return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world) + return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: message(world, 'use-unknown') }]), world) } return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world) } @@ -260,16 +322,16 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World, return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world) } if (command.verb === 'use') { - const burnResult = handleBurnLetter(stateWithNoun, command.target.canonical, command.indirect.canonical, world) - if (burnResult) return withEndingCheck(burnResult, world) + const actionResult = handleDeclarativeAction(stateWithNoun, command, world) + if (actionResult) return withEndingCheck(actionResult, world) const lightResult = handleUseAsLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world) if (lightResult) return withEndingCheck(lightResult, world) - return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world) + return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: message(world, 'use-unknown') }]), world) } return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world) } - return narrate(state, [{ kind: 'narration', text: 'Nothing happens.' }]) + return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-happens') }]) } function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult { @@ -291,11 +353,11 @@ function handleMeta(state: GameState, verb: 'restart' | 'undo' | 'hint' | 'save' function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd', world: World): DispatchResult { const room = world.rooms[state.location] - if (!room) return narrate(state, [{ kind: 'narration', text: 'You are nowhere.' }]) + if (!room) return narrate(state, [{ kind: 'narration', text: message(world, 'nowhere') }]) const dest = room.exits[direction] if (!dest) { - return narrate(state, [{ kind: 'narration', text: 'You can\'t go that way.' }]) + return narrate(state, [{ kind: 'narration', text: message(world, 'no-exit') }]) } const lock = room.lockedExits?.[direction] @@ -307,7 +369,7 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd' } const destRoom = world.rooms[dest] - if (!destRoom) return narrate(state, [{ kind: 'narration', text: 'The way ahead is unfinished.' }]) + if (!destRoom) return narrate(state, [{ kind: 'narration', text: message(world, 'unfinished-exit') }]) const visited = !!state.roomState[dest]?.['visited'] const description = visited ? destRoom.descriptions.revisit : destRoom.descriptions.firstVisit @@ -316,12 +378,10 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd' next = setRoomFlag(next, dest, 'visited', true) if (destRoom.safe) { - const ladder = ['steady', 'shaken', 'reeling', 'returning'] as const - const idx = ladder.indexOf(state.resolveLevel) - if (idx > 0) next = { ...next, resolveLevel: ladder[idx - 1]! } + next = { ...next, resolveLevel: recoverResolve(state.resolveLevel, world) } } - const lightTick = advanceLightState(next, 1, world) + const lightTick = advanceLightState(next, 'move', world) next = lightTick.state const arrivalLines: TranscriptLine[] = [ @@ -346,24 +406,33 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd' } function handleDrink(state: GameState, itemId: string, world: World): DispatchResult { - if (itemId !== 'whiskey') { - return narrate(state, [{ kind: 'narration', text: "You can't drink that." }]) + const action = drunkAction(world) + const targetItems = new Set([ + ...(action.requires?.allHeld ?? []), + ...(action.requires?.allVisibleOrHeld ?? []), + ...(action.consumes?.inventory ?? []), + ]) + if (!action.verbs.includes('drink') || !targetItems.has(itemId)) { + return narrate(state, [{ kind: 'narration', text: message(world, 'cannot-drink') }]) } - const held = state.inventory.some((i) => i.id === 'whiskey') + const requiredHeld = action.requires?.allHeld ?? [...targetItems] + const held = requiredHeld.every((requiredId) => state.inventory.some((i) => i.id === requiredId)) if (!held) { - return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }]) + return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'need-carrying') }]) } - const dest = world.rooms['drunk-hall'] + const config = action.drunkTransition ?? DEFAULT_DRUNK_ACTION.drunkTransition! + const consumed = new Set(action.consumes?.inventory ?? [itemId]) + const dest = world.rooms[config.destinationRoom] const next: GameState = { ...state, - location: 'drunk-hall', - inventory: state.inventory.filter((i) => i.id !== 'whiskey'), + location: config.destinationRoom, + inventory: state.inventory.filter((i) => !consumed.has(i.id)), flags: { ...state.flags, drunk: true, drunkMoves: 0, drunkSecretFound: false }, } - const visited = !!next.roomState['drunk-hall']?.['visited'] - const withVisit = setRoomFlag(next, 'drunk-hall', 'visited', true) + const visited = !!next.roomState[config.destinationRoom]?.['visited'] + const withVisit = setRoomFlag(next, config.destinationRoom, 'visited', true) const lines: TranscriptLine[] = [ - { kind: 'narration', text: 'You drink from the bottle. It tastes of smoke, sugar, and rainwater left too long in a pipe.' }, + { kind: 'narration', text: action.messages.success }, ] if (dest) { lines.push( @@ -377,60 +446,74 @@ function handleDrink(state: GameState, itemId: string, world: World): DispatchRe function maybeResolveDrunkState(result: DispatchResult, world: World): DispatchResult { if (result.state.flags['drunk'] !== true) return result if (result.state.flags['drunkSecretFound'] === true) { - const passed = passOutFromDrunk(result.state, world, 'The faceless man steps backward into the dark. The floor rises under you, or you fall toward it.') + const action = drunkAction(world) + const passed = passOutFromDrunk( + result.state, + world, + action.messages.secretFoundPassOut ?? DEFAULT_DRUNK_ACTION.messages.secretFoundPassOut!, + ) return { state: passed.state, appended: [...result.appended, ...passed.appended] } } return result } function advanceDrunkTurns(state: GameState, world: World): DispatchResult { + const action = drunkAction(world) + const config = action.drunkTransition ?? DEFAULT_DRUNK_ACTION.drunkTransition! const current = typeof state.flags['drunkMoves'] === 'number' ? state.flags['drunkMoves'] : 0 const moves = current + 1 const next = { ...state, flags: { ...state.flags, drunkMoves: moves } } - if (moves < DRUNK_TURNS_MAX) return { state: next, appended: [] } - return passOutFromDrunk(next, world, 'The rooms keep turning until they become one room. Then even that room is gone.') + if (moves < config.maxMoves) return { state: next, appended: [] } + return passOutFromDrunk( + next, + world, + action.messages.tooManyMovesPassOut ?? DEFAULT_DRUNK_ACTION.messages.tooManyMovesPassOut!, + ) } function passOutFromDrunk(state: GameState, world: World, preface: string): DispatchResult { - const foyer = world.rooms['foyer'] - const kitchenState = state.roomState['kitchen'] ?? {} - const kitchenTaken = ((kitchenState['takenItems'] ?? []) as string[]).filter((id) => id !== 'whiskey') - const kitchenDropped = ((kitchenState['droppedItems'] ?? []) as string[]).filter((id) => id !== 'whiskey') + const action = drunkAction(world) + const config = action.drunkTransition ?? DEFAULT_DRUNK_ACTION.drunkTransition! + const resetItem = action.consumes?.inventory?.[0] ?? 'whiskey' + const wakeRoom = world.rooms[config.wakeRoom] + const resetRoomState = state.roomState[config.resetRoom] ?? {} + const resetTaken = ((resetRoomState['takenItems'] ?? []) as string[]).filter((id) => id !== resetItem) + const resetDropped = ((resetRoomState['droppedItems'] ?? []) as string[]).filter((id) => id !== resetItem) const next: GameState = { ...state, - location: 'foyer', - inventory: state.inventory.filter((i) => i.id !== 'whiskey'), + location: config.wakeRoom, + inventory: state.inventory.filter((i) => i.id !== resetItem), flags: { ...state.flags, drunk: false, drunkMoves: 0, drunkSecretFound: false }, roomState: { ...state.roomState, - kitchen: { - ...kitchenState, - takenItems: kitchenTaken, - droppedItems: kitchenDropped, + [config.resetRoom]: { + ...resetRoomState, + takenItems: resetTaken, + droppedItems: resetDropped, }, - foyer: { ...(state.roomState['foyer'] ?? {}), visited: true }, + [config.wakeRoom]: { ...(state.roomState[config.wakeRoom] ?? {}), visited: true }, }, } const lines: TranscriptLine[] = [ { kind: 'narration', text: preface }, - { kind: 'system', text: foyer?.title ?? '[ Foyer ]' }, - { kind: 'narration', text: foyer?.descriptions.revisit ?? 'You wake in the foyer.' }, - { kind: 'narration', text: 'The bottle is not with you. Somewhere in the kitchen, it is half full again.' }, + { kind: 'system', text: wakeRoom?.title ?? `[ ${config.wakeRoom} ]` }, + { kind: 'narration', text: wakeRoom?.descriptions.revisit ?? `You wake in ${config.wakeRoom}.` }, + { kind: 'narration', text: action.messages.reset ?? DEFAULT_DRUNK_ACTION.messages.reset! }, ] return narrate(next, lines) } function handleWait(state: GameState, world: World): DispatchResult { - const lightTick = advanceLightState(state, 1, world) + const lightTick = advanceLightState(state, 'wait', world) return narrate(lightTick.state, [ - { kind: 'narration', text: 'Time passes.' }, + { kind: 'narration', text: message(world, 'time-passes') }, ...lightTick.lines, ]) } function handleLook(state: GameState, world: World): DispatchResult { const room = world.rooms[state.location] - if (!room) return narrate(state, [{ kind: 'narration', text: 'You see nothing.' }]) + if (!room) return narrate(state, [{ kind: 'narration', text: message(world, 'see-nothing') }]) const items = getItemsInRoom(state, world, state.location) const itemNarration = describeRoomItems(items.map((id) => world.items[id]?.short ?? id)) return narrate(state, [ @@ -459,30 +542,31 @@ function joinList(values: string[]): string { function handleInventory(state: GameState, world: World): DispatchResult { if (state.inventory.length === 0) { - return narrate(state, [{ kind: 'narration', text: 'You are empty-handed.' }]) + return narrate(state, [{ kind: 'narration', text: message(world, 'inventory-empty') }]) } const lines = state.inventory.map((inst) => { const item = world.items[inst.id] - const litSuffix = inst.state['lit'] === true ? ' (lit)' : '' + const mechanic = lightMechanic(world) + const litSuffix = mechanic?.enabled && inst.state[mechanic.stateKeys.lit] === true ? ' (lit)' : '' return ` ${item?.short ?? inst.id}${litSuffix}` }) return narrate(state, [ - { kind: 'narration', text: 'You are carrying:' }, + { kind: 'narration', text: message(world, 'inventory-heading') }, { kind: 'narration', text: lines.join('\n') }, ]) } function handleTake(state: GameState, itemId: string, world: World): DispatchResult { const item = world.items[itemId] - if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see that here.' }]) - if (!item.takeable) return narrate(state, [{ kind: 'narration', text: 'You can\'t take that.' }]) + if (!item) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-here') }]) + if (!item.takeable) return narrate(state, [{ kind: 'narration', text: message(world, 'cannot-take') }]) const itemsHere = getItemsInRoom(state, world, state.location) if (!itemsHere.includes(itemId)) { - return narrate(state, [{ kind: 'narration', text: 'You don\'t see that here.' }]) + return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-here') }]) } if (state.inventory.find((i) => i.id === itemId)) { - return narrate(state, [{ kind: 'narration', text: 'You already have it.' }]) + return narrate(state, [{ kind: 'narration', text: message(world, 'already-have') }]) } const wasInRoomBase = (world.rooms[state.location]?.items ?? []).includes(itemId) @@ -497,17 +581,18 @@ function handleTake(state: GameState, itemId: string, world: World): DispatchRes const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[] next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId)) } - return narrate(next, [{ kind: 'narration', text: 'Taken.' }]) + return narrate(next, [{ kind: 'narration', text: message(world, 'taken') }]) } -function handleDrop(state: GameState, itemId: string, _world: World): DispatchResult { +function handleDrop(state: GameState, itemId: string, world: World): DispatchResult { if (!state.inventory.find((i) => i.id === itemId)) { - return narrate(state, [{ kind: 'narration', text: 'You don\'t have that.' }]) + return narrate(state, [{ kind: 'narration', text: message(world, 'dont-have') }]) } - const itemDef = _world.items[itemId] + const itemDef = world.items[itemId] const itemInst = state.inventory.find((i) => i.id === itemId) ?? null - if (itemDef?.lightable && itemInst?.state['lit'] === true) { - return narrate(state, [{ kind: 'narration', text: "Extinguish it first." }]) + const mechanic = lightMechanic(world) + if (mechanic?.enabled && itemDef?.lightable && itemInst?.state[mechanic.stateKeys.lit] === true) { + return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'dropLit', 'drop-lit') }]) } let next: GameState = { ...state, @@ -515,17 +600,17 @@ function handleDrop(state: GameState, itemId: string, _world: World): DispatchRe } const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[] next = setRoomFlag(next, state.location, 'droppedItems', [...dropped, itemId]) - return narrate(next, [{ kind: 'narration', text: 'Dropped.' }]) + return narrate(next, [{ kind: 'narration', text: message(world, 'dropped') }]) } function handleExamine(state: GameState, itemId: string, world: World): DispatchResult { const item = world.items[itemId] - if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }]) + if (!item) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }]) const inventoryInst = state.inventory.find((i) => i.id === itemId) ?? null const visible = inventoryInst || getItemsInRoom(state, world, state.location).includes(itemId) - if (!visible) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }]) + if (!visible) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }]) return narrate(state, [{ kind: 'narration', text: describeItem(itemId, item.long, inventoryInst) }]) } @@ -544,46 +629,49 @@ function spellSmallCount(value: number): string { function handleRead(state: GameState, itemId: string, world: World): DispatchResult { const item = world.items[itemId] - if (!item) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }]) + if (!item) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }]) const visible = state.inventory.find((i) => i.id === itemId) || getItemsInRoom(state, world, state.location).includes(itemId) - if (!visible) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }]) + if (!visible) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }]) if (!item.readable || !item.readableText) { - return narrate(state, [{ kind: 'narration', text: "There's nothing to read on it." }]) + return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-to-read') }]) } return narrate(state, [{ kind: 'narration', text: item.readableText }]) } function handleLight(state: GameState, targetId: string, instrumentId: string | null, world: World): DispatchResult { + const mechanic = lightMechanic(world) + if (!mechanic?.enabled) return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-happens') }]) + const target = world.items[targetId] - if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }]) - if (target.lighter && !target.lightable) return narrate(state, [{ kind: 'narration', text: 'Use match with what?' }]) - if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't light that." }]) + if (!target) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }]) + if (target.lighter && !target.lightable) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'useLighterWithWhat', 'use-lighter-with-what') }]) + if (!target.lightable) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'cannotLight', 'cannot-light') }]) const targetInst = state.inventory.find((i) => i.id === targetId) ?? null const visibleInRoom = getItemsInRoom(state, world, state.location).includes(targetId) if (!targetInst && !visibleInRoom) { - return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }]) + return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }]) } // The 'lit' state lives on the inventory instance for inventory items, or // (eventually) on roomState for items left in a room. For now we only // support lighting items the player is carrying. if (!targetInst) { - return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }]) + return narrate(state, [{ kind: 'narration', text: message(world, 'need-carrying') }]) } - if (targetInst.state['lit'] === true) { - return narrate(state, [{ kind: 'narration', text: "It's already lit." }]) + if (targetInst.state[mechanic.stateKeys.lit] === true) { + return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'alreadyLit', 'already-lit') }]) } // Pick an instrument. If explicit, validate it; if implicit, find any. let lighterInst = null as typeof state.inventory[number] | null if (instrumentId) { lighterInst = state.inventory.find((i) => i.id === instrumentId) ?? null - if (!lighterInst) return narrate(state, [{ kind: 'narration', text: "You don't have that." }]) + if (!lighterInst) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-have') }]) const lighterDef = world.items[instrumentId] - if (!lighterDef?.lighter) return narrate(state, [{ kind: 'narration', text: "That isn't going to help." }]) + if (!lighterDef?.lighter) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'notHelpful', 'not-helpful') }]) if (typeof lighterInst.state['uses'] === 'number' && lighterInst.state['uses'] <= 0) { - return narrate(state, [{ kind: 'narration', text: "It is spent." }]) + return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'spent', 'spent') }]) } } else { for (const inst of state.inventory) { @@ -594,7 +682,7 @@ function handleLight(state: GameState, targetId: string, instrumentId: string | break } if (!lighterInst) { - return narrate(state, [{ kind: 'narration', text: 'You have nothing to light it with.' }]) + return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'noLighter', 'no-lighter') }]) } } @@ -603,64 +691,105 @@ function handleLight(state: GameState, targetId: string, instrumentId: string | const lighterUsesField = typeof lighterInst.state['uses'] === 'number' ? lighterInst.state['uses'] : null const newLighterUses = lighterUsesField === null ? null : lighterUsesField - 1 const newInventory = state.inventory.map((i) => { - if (i.id === targetInst.id) return { ...i, state: { ...i.state, lit: true, burn: LIGHT_TURNS_MAX } } + if (i.id === targetInst.id) return { ...i, state: { ...i.state, [mechanic.stateKeys.lit]: true, [mechanic.stateKeys.burn]: mechanic.maxTurns } } if (i.id === lighterInst!.id && newLighterUses !== null) return { ...i, state: { ...i.state, uses: newLighterUses } } return i }) - const lines: TranscriptLine[] = [{ kind: 'narration', text: target.litText ?? 'It catches.' }] + const lines: TranscriptLine[] = [{ kind: 'narration', text: target.litText ?? lightMessage(world, 'flameCatches', 'flame-catches') }] if (newLighterUses === 0) { - lines.push({ kind: 'narration', text: lighterDef.lighterEmptyText ?? 'It is spent.' }) + lines.push({ kind: 'narration', text: lighterDef.lighterEmptyText ?? lightMessage(world, 'spent', 'spent') }) } return narrate({ ...state, inventory: newInventory }, lines) } -function handleBurnLetter(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null { - const ids = [firstId, secondId] - if (!ids.includes('letter') || !ids.includes('matches')) return null +function handleDeclarativeAction( + state: GameState, + command: Extract, + world: World, +): DispatchResult | null { + const action = findDeclarativeAction(command, world) + if (!action) return null - const matches = state.inventory.find((i) => i.id === 'matches') - if (!matches) return narrate(state, [{ kind: 'narration', text: "You don't have a match." }]) - if (typeof matches.state['uses'] === 'number' && matches.state['uses'] <= 0) { - return narrate(state, [{ kind: 'narration', text: 'The matchbook is empty.' }]) + for (const itemId of action.requires?.allVisibleOrHeld ?? []) { + if (!isVisibleOrHeld(state, world, itemId)) { + return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'dont-see-anything') }]) + } + } + for (const itemId of action.requires?.allHeld ?? []) { + if (!state.inventory.some((i) => i.id === itemId)) { + return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'dont-have') }]) + } } - const letterHeld = state.inventory.some((i) => i.id === 'letter') - const letterInRoom = getItemsInRoom(state, world, state.location).includes('letter') - if (!letterHeld && !letterInRoom) { - return narrate(state, [{ kind: 'narration', text: "You don't see the letter here." }]) + const decrement = action.decrements + const decremented = decrement ? state.inventory.find((i) => i.id === decrement.item) : null + if (decrement && !decremented) { + return narrate(state, [{ kind: 'narration', text: action.messages.missingRequired ?? message(world, 'dont-have') }]) + } + const decrementStateValue = decrement ? decremented?.state[decrement.stateKey] : null + const decrementedValue: number | null = typeof decrementStateValue === 'number' ? decrementStateValue : null + if (decrementedValue !== null && decrementedValue <= 0) { + return narrate(state, [{ kind: 'narration', text: action.messages.spent ?? message(world, 'spent') }]) } - const newMatchesUses = typeof matches.state['uses'] === 'number' ? matches.state['uses'] - 1 : null + const consumed = new Set(action.consumes?.inventory ?? []) let next: GameState = { ...state, inventory: state.inventory - .filter((i) => i.id !== 'letter') - .map((i) => i.id === 'matches' && newMatchesUses !== null ? { ...i, state: { ...i.state, uses: newMatchesUses } } : i), - flags: { ...state.flags, letterBurned: true }, + .filter((i) => !consumed.has(i.id)) + .map((i) => + decrement && i.id === decrement.item && decrementedValue !== null + ? { ...i, state: { ...i.state, [decrement.stateKey]: decrementedValue - 1 } } + : i, + ), + flags: { ...state.flags, ...(action.setsFlags ?? {}) }, } - if (letterInRoom) { - const baseItems = world.rooms[state.location]?.items ?? [] - const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[] - if (baseItems.includes('letter')) { - const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[] - next = setRoomFlag(next, state.location, 'takenItems', [...new Set([...taken, 'letter'])]) - } - if (dropped.includes('letter')) { - next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== 'letter')) - } + for (const itemId of consumed) { + next = removeVisibleRoomItem(next, world, itemId) } - const lines: TranscriptLine[] = [ - { kind: 'narration', text: 'The letter catches at one corner. In a few breaths it is ash.' }, - ] - if (newMatchesUses === 0) { - lines.push({ kind: 'narration', text: world.items['matches']?.lighterEmptyText ?? 'The matchbook is empty.' }) + const lines: TranscriptLine[] = [{ kind: 'narration', text: action.messages.success }] + if (decrementedValue === 1 && action.messages.spent) { + lines.push({ kind: 'narration', text: action.messages.spent }) } return narrate(next, lines) } +function findDeclarativeAction( + command: Extract, + world: World, +): DeclarativeAction | null { + const commandItems = new Set([command.target.canonical, command.indirect.canonical]) + for (const action of Object.values(world.actions ?? {})) { + if (!action.verbs.includes(command.verb)) continue + const required = [...(action.requires?.allVisibleOrHeld ?? []), ...(action.requires?.allHeld ?? [])] + if (required.length > 0 && required.every((itemId) => commandItems.has(itemId))) return action + } + return null +} + +function isVisibleOrHeld(state: GameState, world: World, itemId: string): boolean { + return state.inventory.some((i) => i.id === itemId) || getItemsInRoom(state, world, state.location).includes(itemId) +} + +function removeVisibleRoomItem(state: GameState, world: World, itemId: string): GameState { + if (!getItemsInRoom(state, world, state.location).includes(itemId)) return state + const baseItems = world.rooms[state.location]?.items ?? [] + let next = state + if (baseItems.includes(itemId)) { + const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[] + next = setRoomFlag(next, state.location, 'takenItems', [...new Set([...taken, itemId])]) + } + const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[] + if (dropped.includes(itemId)) { + next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId)) + } + return next +} + function handleUseAsLight(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null { + if (!lightMechanic(world)?.enabled) return null const first = world.items[firstId] const second = world.items[secondId] if (first?.lighter && second?.lightable) return handleLight(state, secondId, firstId, world) @@ -669,45 +798,50 @@ function handleUseAsLight(state: GameState, firstId: string, secondId: string, w } function handleExtinguish(state: GameState, targetId: string, world: World): DispatchResult { + const mechanic = lightMechanic(world) + if (!mechanic?.enabled) return narrate(state, [{ kind: 'narration', text: message(world, 'nothing-happens') }]) + const target = world.items[targetId] - if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }]) - if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't extinguish that." }]) + if (!target) return narrate(state, [{ kind: 'narration', text: message(world, 'dont-see-anything') }]) + if (!target.lightable) return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'cannotExtinguish', 'cannot-extinguish') }]) const targetInst = state.inventory.find((i) => i.id === targetId) - if (!targetInst) return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }]) - if (targetInst.state['lit'] !== true) { - return narrate(state, [{ kind: 'narration', text: "It isn't lit." }]) + if (!targetInst) return narrate(state, [{ kind: 'narration', text: message(world, 'need-carrying') }]) + if (targetInst.state[mechanic.stateKeys.lit] !== true) { + return narrate(state, [{ kind: 'narration', text: lightMessage(world, 'notLit', 'not-lit') }]) } const newInventory = state.inventory.map((i) => - i.id === targetId ? { ...i, state: { ...i.state, lit: false, burn: 0 } } : i, + i.id === targetId ? { ...i, state: { ...i.state, [mechanic.stateKeys.lit]: false, [mechanic.stateKeys.burn]: 0 } } : i, ) - return narrate({ ...state, inventory: newInventory }, [{ kind: 'narration', text: target.extinguishedText ?? 'The flame dies.' }]) + return narrate({ ...state, inventory: newInventory }, [{ kind: 'narration', text: target.extinguishedText ?? lightMessage(world, 'flameDies', 'flame-dies') }]) } -function advanceLightState(state: GameState, cost: number, world: World): { state: GameState; lines: TranscriptLine[] } { - if (cost <= 0) return { state, lines: [] } +function advanceLightState(state: GameState, trigger: 'move' | 'wait', world: World): { state: GameState; lines: TranscriptLine[] } { + const mechanic = lightMechanic(world) + if (!mechanic?.enabled || !mechanic.burnOn.includes(trigger)) return { state, lines: [] } let changed = false const lines: TranscriptLine[] = [] const inventory = state.inventory.map((inst) => { const def = world.items[inst.id] - if (!def?.lightable || inst.state['lit'] !== true) return inst + if (!def?.lightable || inst.state[mechanic.stateKeys.lit] !== true) return inst - const turnsLeft = getLightTurnsLeft(inst) - const nextTurns = Math.max(0, turnsLeft - cost) + const turnsLeft = getLightTurnsLeft(inst, world) + const nextTurns = Math.max(0, turnsLeft - 1) changed = true if (nextTurns === 0) { - lines.push({ kind: 'narration', text: def.extinguishedText ?? 'The flame dies.' }) - return { ...inst, state: { ...inst.state, lit: false, burn: 0 } } + lines.push({ kind: 'narration', text: def.extinguishedText ?? lightMessage(world, 'flameDies', 'flame-dies') }) + return { ...inst, state: { ...inst.state, [mechanic.stateKeys.lit]: false, [mechanic.stateKeys.burn]: 0 } } } - return { ...inst, state: { ...inst.state, burn: nextTurns } } + return { ...inst, state: { ...inst.state, [mechanic.stateKeys.burn]: nextTurns } } }) return changed ? { state: { ...state, inventory }, lines } : { state, lines } } -function getLightTurnsLeft(inst: ItemInstance): number { - const turns = inst.state['burn'] +function getLightTurnsLeft(inst: ItemInstance, world: World): number { + const mechanic = lightMechanic(world) + const turns = inst.state[mechanic.stateKeys.burn] if (typeof turns === 'number') return Math.max(0, turns) - return inst.state['lit'] === true ? LIGHT_TURNS_MAX : 0 + return inst.state[mechanic.stateKeys.lit] === true ? mechanic.maxTurns : 0 } diff --git a/src/engine/encounters.test.ts b/src/engine/encounters.test.ts index 2f1ff8e..1b48935 100644 --- a/src/engine/encounters.test.ts +++ b/src/engine/encounters.test.ts @@ -92,6 +92,23 @@ const world: World = { }, } +function withResolveMechanic(overrides: Partial['resolve']>>): World { + return { + ...world, + mechanics: { + resolve: { + enabled: true, + handler: 'resolve', + ladder: ['steady', 'shaken', 'reeling', 'returning'], + wrongVerbCost: 1, + safeRooms: { recoverySteps: 1 }, + failure: { retreatAt: 'returning', afterRetreat: 'shaken' }, + ...overrides, + }, + }, + } +} + describe('encounters — phase advancement', () => { it('triggers an encounter on entering its room', () => { let s = initialStateFor(world) @@ -157,6 +174,34 @@ describe('encounters — phase advancement', () => { expect(s.resolveLevel).toBe('steady') }) + it('uses markdown resolve config for wrong-verb cost', () => { + const configured = withResolveMechanic({ wrongVerbCost: 2 }) + let s = initialStateFor(configured) + s = dispatch(s, { kind: 'go', direction: 'n' }, configured).state + const r = dispatch(s, { kind: 'verb-target', verb: 'push', target: { canonical: 'revenant', raw: 'revenant' } }, configured) + expect(r.state.resolveLevel).toBe('reeling') + }) + + it('uses markdown resolve config for safe-room recovery', () => { + const configured = withResolveMechanic({ safeRooms: { recoverySteps: 2 } }) + let s = initialStateFor(configured) + s = { ...s, resolveLevel: 'reeling' } + s = dispatch(s, { kind: 'go', direction: 'n' }, configured).state + s = dispatch(s, { kind: 'go', direction: 's' }, configured).state + expect(s.resolveLevel).toBe('steady') + }) + + it('uses markdown resolve config for post-retreat level', () => { + const configured = withResolveMechanic({ failure: { retreatAt: 'returning', afterRetreat: 'reeling' } }) + let s = initialStateFor(configured) + s = dispatch(s, { kind: 'go', direction: 'n' }, configured).state + s = { ...s, resolveLevel: 'returning' } + s = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, configured).state + const r = dispatch(s, { kind: 'confirmation', confirmed: true }, configured) + expect(r.state.location).toBe('foyer') + expect(r.state.resolveLevel).toBe('reeling') + }) + it('allows a required item to be the direct target in a target-preposition encounter command', () => { let s = initialStateFor(world) s = { diff --git a/src/engine/encounters.ts b/src/engine/encounters.ts index 45473bf..4810f94 100644 --- a/src/engine/encounters.ts +++ b/src/engine/encounters.ts @@ -2,6 +2,17 @@ import type { World } from '../world/types' import type { GameState, ParsedCommand, DispatchResult, TranscriptLine, ResolveLevel } from './types' import { TRANSCRIPT_CAP, RESOLVE_LEVELS } from './types' +type ActiveResolveMechanic = NonNullable['resolve']> + +const DEFAULT_RESOLVE_MECHANIC: ActiveResolveMechanic = { + enabled: true, + handler: 'resolve', + ladder: RESOLVE_LEVELS, + wrongVerbCost: 1, + safeRooms: { recoverySteps: 1 }, + failure: { retreatAt: 'returning', afterRetreat: 'shaken' }, +} + function append(state: GameState, lines: TranscriptLine[]): GameState { const transcript = [...state.transcript, ...lines] return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) } @@ -38,11 +49,26 @@ export function maybeTriggerEncounter(state: GameState, world: World): DispatchR return narrate(next, [{ kind: 'narration', text: phase.description }]) } -function bumpResolve(level: ResolveLevel, cost: 0 | 1 | 2 | undefined): ResolveLevel { - if (!cost) return level - const idx = RESOLVE_LEVELS.indexOf(level) - const newIdx = Math.min(RESOLVE_LEVELS.length - 1, idx + cost) - return RESOLVE_LEVELS[newIdx]! +function resolveMechanic(world: World): ActiveResolveMechanic { + return world.mechanics?.resolve ?? DEFAULT_RESOLVE_MECHANIC +} + +function bumpResolve(level: ResolveLevel, cost: 0 | 1 | 2 | undefined, world: World): ResolveLevel { + const mechanic = resolveMechanic(world) + if (!mechanic.enabled || !cost) return level + const idx = mechanic.ladder.indexOf(level) + if (idx < 0) return level + const newIdx = Math.min(mechanic.ladder.length - 1, idx + cost) + return mechanic.ladder[newIdx]! +} + +function shouldRetreat(level: ResolveLevel, cost: 0 | 1 | 2 | undefined, world: World): boolean { + const mechanic = resolveMechanic(world) + return mechanic.enabled && !!cost && level === mechanic.failure.retreatAt +} + +function afterRetreatResolve(world: World): ResolveLevel { + return resolveMechanic(world).failure.afterRetreat } export interface EncounterResolution { @@ -103,12 +129,13 @@ export function applyVerbToEncounter( if (!transition) { // Wrong verb — apply default narration and resolve cost. if (!verb || (targetId !== null && targetId !== encId)) return null // verb is unrelated to this encounter - const newResolve = bumpResolve(state.resolveLevel, 1) - if (state.resolveLevel === 'returning') { + const wrongVerbCost = resolveMechanic(world).wrongVerbCost + const newResolve = bumpResolve(state.resolveLevel, wrongVerbCost, world) + if (shouldRetreat(state.resolveLevel, wrongVerbCost, world)) { // Retreat. const retreat = def.onFailed if (retreat) { - const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: 'shaken' } + const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: afterRetreatResolve(world) } const dest = world.rooms[retreat.retreatTo] const lines: TranscriptLine[] = [ { kind: 'narration', text: retreat.narration }, @@ -125,10 +152,10 @@ export function applyVerbToEncounter( } // Right verb — but if it has a resolve cost and player is already at 'returning', retreat. - if (transition.resolveCost && transition.resolveCost > 0 && state.resolveLevel === 'returning') { + if (shouldRetreat(state.resolveLevel, transition.resolveCost, world)) { const retreat = def.onFailed if (retreat) { - const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: 'shaken' } + const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: afterRetreatResolve(world) } const dest = world.rooms[retreat.retreatTo] const lines: TranscriptLine[] = [ { kind: 'narration', text: transition.narration }, @@ -142,7 +169,7 @@ export function applyVerbToEncounter( // Right verb — narrate and transition. let next: GameState = { ...state } if (transition.resolveCost) { - next = { ...next, resolveLevel: bumpResolve(next.resolveLevel, transition.resolveCost) } + next = { ...next, resolveLevel: bumpResolve(next.resolveLevel, transition.resolveCost, world) } } if (transition.to === 'resolved') { @@ -158,7 +185,7 @@ export function applyVerbToEncounter( const dest = world.rooms[retreat.retreatTo] const newEncState = { ...next.encounterState } delete newEncState[encId] - next = { ...next, location: retreat.retreatTo, encounterState: newEncState, resolveLevel: 'shaken' } + next = { ...next, location: retreat.retreatTo, encounterState: newEncState, resolveLevel: afterRetreatResolve(world) } const lines: TranscriptLine[] = [ { kind: 'narration', text: transition.narration }, { kind: 'narration', text: retreat.narration }, diff --git a/src/engine/parser.test.ts b/src/engine/parser.test.ts index 803a35f..6622f1c 100644 --- a/src/engine/parser.test.ts +++ b/src/engine/parser.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { parse } from './parser' -import type { ParserContext } from './parser' +import type { ParserContext, ParserVocabulary } from './parser' const emptyCtx: ParserContext = { knownItems: [], @@ -92,6 +92,45 @@ describe('parser — unknown input', () => { }) describe('parser — verb + target', () => { + it('uses vocabulary supplied by world markdown', () => { + const vocabulary: ParserVocabulary = { + directions: { + n: ['n', 'northward'], + s: ['s'], + e: ['e'], + w: ['w'], + u: ['u'], + d: ['d'], + }, + prepositions: ['beside'], + stopWords: ['the'], + noTargetVerbs: ['look'], + metaVerbs: ['restart'], + verbs: { + go: ['go'], + look: ['look', 'observe'], + take: ['take hold of'], + }, + } + const ctx: ParserContext = { + knownItems: ['lamp'], + knownEncounters: [], + visibleNouns: [{ id: 'lamp', aliases: ['lamp'] }], + inventoryItemIds: [], + lastNoun: null, + awaitingDisambiguation: null, + vocabulary, + } + expect(parse('observe', ctx)).toEqual({ kind: 'verb-only', verb: 'look' }) + expect(parse('northward', ctx)).toEqual({ kind: 'go', direction: 'n' }) + expect(parse('go northward', ctx)).toEqual({ kind: 'go', direction: 'n' }) + expect(parse('take hold of the lamp', ctx)).toEqual({ + kind: 'verb-target', + verb: 'take', + target: { canonical: 'lamp', raw: 'lamp' }, + }) + }) + it('recognizes slice-two encounter verbs', () => { const ctx: ParserContext = { knownItems: [], diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 839bcc0..1df846c 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -1,5 +1,14 @@ import type { ParsedCommand, NounRef, Verb, MetaVerb, Direction, PendingDisambiguation } from './types' +export interface ParserVocabulary { + directions: Record + prepositions: string[] + stopWords: string[] + noTargetVerbs: Verb[] + metaVerbs: MetaVerb[] + verbs: Partial> +} + export interface ParserContext { /** All item ids that exist in the world (for noun matching). */ knownItems: string[] @@ -11,64 +20,115 @@ export interface ParserContext { inventoryItemIds: string[] lastNoun: NounRef | null awaitingDisambiguation: PendingDisambiguation | null + vocabulary?: ParserVocabulary } -/** Verb synonym table: each entry maps an alias to the canonical Verb. */ -const VERB_SYNONYMS: Record = { - // movement - go: 'go', walk: 'go', move: 'go', - // perception - look: 'look', l: 'look', - examine: 'examine', x: 'examine', inspect: 'examine', - // inventory - inventory: 'inventory', inv: 'inventory', i: 'inventory', - // manipulation - take: 'take', get: 'take', grab: 'take', 'pick up': 'take', - drop: 'drop', put: 'drop', leave: 'drop', - use: 'use', combine: 'use', - open: 'open', close: 'close', - drink: 'drink', sip: 'drink', - read: 'read', light: 'light', extinguish: 'extinguish', douse: 'extinguish', - attack: 'attack', kill: 'attack', fight: 'attack', strike: 'attack', - hold: 'hold', show: 'hold', - push: 'push', press: 'push', - pull: 'pull', - cut: 'cut', trim: 'cut', - play: 'play', - listen: 'listen', - pour: 'pour', - uncover: 'open', - wait: 'wait', z: 'wait', +export const SUPPORTED_VERBS: Verb[] = [ + 'go', + 'look', + 'examine', + 'take', + 'drop', + 'use', + 'open', + 'close', + 'read', + 'light', + 'extinguish', + 'attack', + 'inventory', + 'wait', + 'hold', + 'push', + 'pull', + 'cut', + 'play', + 'listen', + 'pour', + 'drink', +] + +export const SUPPORTED_META_VERBS: MetaVerb[] = ['restart', 'undo', 'hint', 'save', 'quit', 'theme'] + +export const DEFAULT_PARSER_VOCABULARY: ParserVocabulary = { + directions: { + n: ['n', 'north'], + s: ['s', 'south'], + e: ['e', 'east'], + w: ['w', 'west'], + u: ['u', 'up'], + d: ['d', 'down'], + }, + prepositions: ['with', 'on', 'in', 'to'], + stopWords: ['at', 'the', 'a', 'an'], + noTargetVerbs: ['look', 'inventory', 'wait', 'listen'], + metaVerbs: ['restart', 'undo', 'hint', 'save', 'quit', 'theme'], + verbs: { + go: ['go', 'walk', 'move'], + look: ['look', 'l'], + examine: ['examine', 'x', 'inspect'], + inventory: ['inventory', 'inv', 'i'], + take: ['take', 'get', 'grab', 'pick up'], + drop: ['drop', 'put', 'leave'], + use: ['use', 'combine'], + open: ['open', 'uncover'], + close: ['close'], + drink: ['drink', 'sip'], + read: ['read'], + light: ['light'], + extinguish: ['extinguish', 'douse'], + attack: ['attack', 'kill', 'fight', 'strike'], + hold: ['hold', 'show'], + push: ['push', 'press'], + pull: ['pull'], + cut: ['cut', 'trim'], + play: ['play'], + listen: ['listen'], + pour: ['pour'], + wait: ['wait', 'z'], + }, } -const DIRECTION_WORDS: Record = { - n: 'n', north: 'n', - s: 's', south: 's', - e: 'e', east: 'e', - w: 'w', west: 'w', - u: 'u', up: 'u', - d: 'd', down: 'd', +interface CompiledVocabulary { + directionWords: Record + metaVerbs: Record + verbSynonyms: Record + multiWordVerbs: string[] + noTargetVerbs: Set + stopWords: Set + prepositions: Set } -const META_VERBS: Record = { - restart: 'restart', - undo: 'undo', - hint: 'hint', - save: 'save', - quit: 'quit', - theme: 'theme', +function normalizeAlias(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, ' ') } -/** Verbs that legally take no target. */ -const VERB_ONLY_VERBS = new Set(['look', 'inventory', 'wait', 'listen']) +function compileVocabulary(vocabulary: ParserVocabulary): CompiledVocabulary { + const directionWords: Record = {} + for (const [direction, aliases] of Object.entries(vocabulary.directions) as [Direction, string[]][]) { + for (const alias of aliases) directionWords[normalizeAlias(alias)] = direction + } -/** Two-word verb prefixes (e.g. "pick up X"). */ -const TWO_WORD_VERBS = ['pick up'] + const metaVerbs: Record = {} + for (const verb of vocabulary.metaVerbs) metaVerbs[normalizeAlias(verb)] = verb -/** Leading stop-words stripped from the noun phrase before matching. */ -const STOP_WORDS = new Set(['at', 'the', 'a', 'an']) + const verbSynonyms: Record = {} + for (const [verb, aliases] of Object.entries(vocabulary.verbs) as [Verb, string[]][]) { + for (const alias of aliases) verbSynonyms[normalizeAlias(alias)] = verb + } -const PREPOSITIONS = new Set(['with', 'on', 'in', 'to']) + return { + directionWords, + metaVerbs, + verbSynonyms, + multiWordVerbs: Object.keys(verbSynonyms) + .filter((alias) => alias.includes(' ')) + .sort((a, b) => b.split(' ').length - a.split(' ').length), + noTargetVerbs: new Set(vocabulary.noTargetVerbs), + stopWords: new Set(vocabulary.stopWords.map(normalizeAlias)), + prepositions: new Set(vocabulary.prepositions.map(normalizeAlias)), + } +} function resolveNoun(rawTokens: string[], ctx: ParserContext): { id: string; alias: string } | null { const phrase = rawTokens.join(' ') @@ -90,19 +150,19 @@ function tokenize(input: string): string[] { return input.trim().toLowerCase().split(/\s+/).filter(Boolean) } -function matchTwoWordVerb(tokens: string[]): { verb: Verb; rest: string[] } | null { - if (tokens.length < 2) return null - const head = tokens.slice(0, 2).join(' ') - for (const phrase of TWO_WORD_VERBS) { - if (phrase === head) { - const verb = VERB_SYNONYMS[phrase] - if (verb) return { verb, rest: tokens.slice(2) } +function matchMultiWordVerb(tokens: string[], vocabulary: CompiledVocabulary): { verb: Verb; rest: string[] } | null { + for (const phrase of vocabulary.multiWordVerbs) { + const phraseTokens = phrase.split(' ') + if (tokens.length >= phraseTokens.length && tokens.slice(0, phraseTokens.length).join(' ') === phrase) { + const verb = vocabulary.verbSynonyms[phrase] + if (verb) return { verb, rest: tokens.slice(phraseTokens.length) } } } return null } export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { + const vocabulary = compileVocabulary(ctx.vocabulary ?? DEFAULT_PARSER_VOCABULARY) const trimmed = rawInput.trim() if (!trimmed) return { kind: 'unknown', raw: '', reason: 'malformed' } @@ -117,19 +177,14 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { } // Meta-commands take precedence (single-word). - if (META_VERBS[head] && tokens.length === 1) { - return { kind: 'meta', verb: META_VERBS[head]! } + if (vocabulary.metaVerbs[head] && tokens.length === 1) { + return { kind: 'meta', verb: vocabulary.metaVerbs[head]! } } // Direction shortcuts: "n", "north", "go n", "go north". - if (DIRECTION_WORDS[head] && tokens.length === 1) { - return { kind: 'go', direction: DIRECTION_WORDS[head]! } + if (vocabulary.directionWords[head] && tokens.length === 1) { + return { kind: 'go', direction: vocabulary.directionWords[head]! } } - if (head === 'go' && tokens.length === 2) { - const dir = DIRECTION_WORDS[tokens[1]!] - if (dir) return { kind: 'go', direction: dir } - } - // Disambiguation reply: a single-word answer matching one of the candidates. // Must be checked before verb resolution so "brass" / "iron" etc. are caught. if (ctx.awaitingDisambiguation && tokens.length === 1) { @@ -144,15 +199,15 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { } } - // Two-word verb (e.g. "pick up X"). - const twoWord = matchTwoWordVerb(tokens) + // Multi-word verb aliases (e.g. "pick up X"). + const twoWord = matchMultiWordVerb(tokens, vocabulary) let verb: Verb | undefined let rest: string[] if (twoWord) { verb = twoWord.verb rest = twoWord.rest } else { - verb = VERB_SYNONYMS[head] + verb = vocabulary.verbSynonyms[head] rest = tokens.slice(1) } @@ -160,26 +215,31 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { return { kind: 'unknown', raw: trimmed, reason: 'unknown-verb' } } + if (verb === 'go' && rest.length === 1) { + const dir = vocabulary.directionWords[rest[0]!] + if (dir) return { kind: 'go', direction: dir } + } + // Strip leading stop-words from the noun phrase (e.g. "at", "the", "a", "an"). - while (rest.length > 0 && STOP_WORDS.has(rest[0]!)) { + while (rest.length > 0 && vocabulary.stopWords.has(rest[0]!)) { rest = rest.slice(1) } if (rest.length === 0) { - if (VERB_ONLY_VERBS.has(verb)) { + if (vocabulary.noTargetVerbs.has(verb)) { return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' | 'listen' } } return { kind: 'unknown', raw: trimmed, reason: 'malformed' } } // Detect a preposition splitting target | indirect. - const prepIdx = rest.findIndex((tok) => PREPOSITIONS.has(tok)) + const prepIdx = rest.findIndex((tok) => vocabulary.prepositions.has(tok)) if (prepIdx > 0 && prepIdx < rest.length - 1) { const targetTokens = rest.slice(0, prepIdx) const prep = rest[prepIdx]! let indirectTokens = rest.slice(prepIdx + 1) // Strip stop-words at the head of the indirect phrase too ("on the table"). - while (indirectTokens.length > 0 && STOP_WORDS.has(indirectTokens[0]!)) { + while (indirectTokens.length > 0 && vocabulary.stopWords.has(indirectTokens[0]!)) { indirectTokens = indirectTokens.slice(1) } if (indirectTokens.length > 0) { diff --git a/src/engine/playthrough.test.ts b/src/engine/playthrough.test.ts index aa6c599..3ca3263 100644 --- a/src/engine/playthrough.test.ts +++ b/src/engine/playthrough.test.ts @@ -30,6 +30,7 @@ function ctxFor(state: GameState): ParserContext { inventoryItemIds: state.inventory.map((i) => i.id), lastNoun: state.lastNoun, awaitingDisambiguation: state.pendingDisambiguation, + vocabulary: world.parser, } } diff --git a/src/engine/types.ts b/src/engine/types.ts index 78a9e85..fb7f2de 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -40,7 +40,7 @@ export interface ItemInstance { } export type EncounterPhase = string // phase names are encounter-specific -export type EndingId = 'true' | 'wrong' | 'bad' | 'replacement' | 'mercy' +export type EndingId = string export interface TranscriptLine { kind: 'narration' | 'player' | 'system' | 'ending' @@ -60,6 +60,7 @@ export interface PendingConfirmation { export interface GameState { schemaVersion: number + transcriptCap?: number location: RoomId inventory: ItemInstance[] /** Per-room state: visited, items dropped, descriptive flags. */ diff --git a/src/pages/index.astro b/src/pages/index.astro index 84d9469..bd8736b 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,22 +1,27 @@ --- import '../ui/crt.css' +import { world } from '../world' const buildNumber = process.env.CI_PIPELINE_NUMBER ?? 'local' +const ui = world.ui +const footerLinks = ui?.footer.links ?? [] +const firstFooterLink = footerLinks[0] +const remainingFooterLinks = footerLinks.slice(1) --- - Halfstreet — Ethan J Lewis - - + {ui?.pageTitle ?? `${world.game?.title ?? 'Halfstreet'} - Ethan J Lewis`} + + - +
@@ -52,16 +57,20 @@ const buildNumber = process.env.CI_PIPELINE_NUMBER ?? 'local'
Game
- - + {ui?.features.chips !== false && ( + <> + + + + )}
-
- + {ui?.features.chips !== false &&
} + {ui?.features.lightMeter !== false && }