feat(ui): add confirmations and terminal motion
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-12 19:44:18 -05:00
parent cc98aa180b
commit 0755213d6a
12 changed files with 263 additions and 29 deletions
+47 -6
View File
@@ -46,6 +46,7 @@ export function initialStateFor(world: World): GameState {
encounterState: {}, encounterState: {},
lastNoun: null, lastNoun: null,
pendingDisambiguation: null, pendingDisambiguation: null,
pendingConfirmation: null,
transcript: opening, transcript: opening,
endedWith: null, endedWith: null,
} }
@@ -120,7 +121,51 @@ function withEndingCheck(result: DispatchResult, world: World): DispatchResult {
return { state: updated, appended: [...result.appended, endingLine] } return { state: updated, appended: [...result.appended, endingLine] }
} }
export function dispatch(state: GameState, command: ParsedCommand, world: World): DispatchResult { const CRITICAL_VERBS = new Set(['attack'])
function isCriticalCommand(command: ParsedCommand): boolean {
if (command.kind !== 'verb-target' && command.kind !== 'verb-target-prep') return false
return CRITICAL_VERBS.has(command.verb)
}
function confirmationPrompt(command: ParsedCommand): string {
if (command.kind === 'verb-target') {
return `Are you sure you want to ${command.verb} ${command.target.raw}? Type yes to continue, or no to stop.`
}
if (command.kind === 'verb-target-prep') {
return `Are you sure you want to ${command.verb} ${command.target.raw} ${command.preposition} ${command.indirect.raw}? Type yes to continue, or no to stop.`
}
return 'Are you sure? Type yes to continue, or no to stop.'
}
export function dispatch(state: GameState, command: ParsedCommand, world: World, confirmed = false): DispatchResult {
if (command.kind === 'confirmation') {
const pending = state.pendingConfirmation
if (!pending) {
return narrate(state, [{ kind: 'narration', text: 'Nothing to confirm.' }])
}
const cleared: GameState = { ...state, pendingConfirmation: null }
if (!command.confirmed) {
return narrate(cleared, [{ kind: 'narration', text: 'Cancelled.' }])
}
return dispatch(cleared, pending.command, world, true)
}
if (state.pendingConfirmation) {
state = { ...state, pendingConfirmation: null }
}
// 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`.' }])
}
if (!confirmed && isCriticalCommand(command)) {
const prompt = confirmationPrompt(command)
const next: GameState = { ...state, pendingConfirmation: { command, prompt } }
return narrate(next, [{ kind: 'narration', text: prompt }])
}
// Disambiguation reply: re-issue the original verb with the chosen target. // Disambiguation reply: re-issue the original verb with the chosen target.
if (command.kind === 'disambiguation') { if (command.kind === 'disambiguation') {
const pending = state.pendingDisambiguation const pending = state.pendingDisambiguation
@@ -132,14 +177,10 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
cleared, cleared,
{ kind: 'verb-target', verb: pending.verb, target: { canonical: command.chosen, raw: command.chosen } }, { kind: 'verb-target', verb: pending.verb, target: { canonical: command.chosen, raw: command.chosen } },
world, world,
confirmed,
) )
} }
// 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`.' }])
}
if (command.kind === 'unknown') { if (command.kind === 'unknown') {
const text = const text =
command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.' command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.'
+17 -2
View File
@@ -112,11 +112,25 @@ describe('encounters — phase advancement', () => {
it('wrong verb costs resolve and surfaces a clue', () => { it('wrong verb costs resolve and surfaces a clue', () => {
let s = initialStateFor(world) let s = initialStateFor(world)
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world) const prompt = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
expect(prompt.state.pendingConfirmation).toBeDefined()
expect(prompt.appended.at(-1)?.text).toContain('Are you sure')
const r = dispatch(prompt.state, { kind: 'confirmation', confirmed: true }, world)
expect(r.state.resolveLevel).toBe('shaken') expect(r.state.resolveLevel).toBe('shaken')
expect(r.state.encounterState['revenant']).toBe('shaken') expect(r.state.encounterState['revenant']).toBe('shaken')
}) })
it('cancels a confirmed attack when the player says no', () => {
let s = initialStateFor(world)
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
const prompt = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world)
const r = dispatch(prompt.state, { kind: 'confirmation', confirmed: false }, world)
expect(r.state.pendingConfirmation).toBeNull()
expect(r.state.resolveLevel).toBe('steady')
expect(r.state.encounterState['revenant']).toBe('wary')
expect(r.appended.at(-1)?.text).toBe('Cancelled.')
})
it('falls back to defaultWrongVerbNarration for unrecognized verbs', () => { it('falls back to defaultWrongVerbNarration for unrecognized verbs', () => {
let s = initialStateFor(world) let s = initialStateFor(world)
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
@@ -129,7 +143,8 @@ describe('encounters — phase advancement', () => {
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
// Force resolve to 'returning' so the next failure retreats. // Force resolve to 'returning' so the next failure retreats.
s = { ...s, resolveLevel: 'returning' } s = { ...s, resolveLevel: 'returning' }
const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world) s = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world).state
const r = dispatch(s, { kind: 'confirmation', confirmed: true }, world)
expect(r.state.location).toBe('foyer') expect(r.state.location).toBe('foyer')
expect(r.appended.some((l) => l.text.includes('stagger back'))).toBe(true) expect(r.appended.some((l) => l.text.includes('stagger back'))).toBe(true)
}) })
+12
View File
@@ -36,6 +36,18 @@ describe('parser — verb-only commands', () => {
}) })
}) })
describe('parser — confirmations', () => {
it('recognizes yes and no confirmation replies', () => {
expect(parse('yes', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: true })
expect(parse('y', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: true })
expect(parse('no', emptyCtx)).toEqual({ kind: 'confirmation', confirmed: false })
})
it('keeps n as the north direction shortcut', () => {
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
})
})
describe('parser — direction shortcuts', () => { describe('parser — direction shortcuts', () => {
it('maps single-letter directions', () => { it('maps single-letter directions', () => {
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' }) expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
+7
View File
@@ -108,6 +108,13 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
const tokens = tokenize(trimmed) const tokens = tokenize(trimmed)
const head = tokens[0]! const head = tokens[0]!
if (tokens.length === 1 && ['yes', 'y'].includes(head)) {
return { kind: 'confirmation', confirmed: true }
}
if (tokens.length === 1 && head === 'no') {
return { kind: 'confirmation', confirmed: false }
}
// Meta-commands take precedence (single-word). // Meta-commands take precedence (single-word).
if (META_VERBS[head] && tokens.length === 1) { if (META_VERBS[head] && tokens.length === 1) {
return { kind: 'meta', verb: META_VERBS[head]! } return { kind: 'meta', verb: META_VERBS[head]! }
+1
View File
@@ -51,6 +51,7 @@ describe('playthrough — sample world', () => {
'take lamp', 'take lamp',
'e', // hallway → cellar-stair (triggers rat encounter) 'e', // hallway → cellar-stair (triggers rat encounter)
'attack rat', 'attack rat',
'yes',
]) ])
expect(state.flags['ratGone']).toBe(true) expect(state.flags['ratGone']).toBe(true)
expect(state.location).toBe('cellar-stair') expect(state.location).toBe('cellar-stair')
+1
View File
@@ -13,6 +13,7 @@ const baseState = (overrides: Partial<GameState> = {}): GameState => ({
encounterState: {}, encounterState: {},
lastNoun: null, lastNoun: null,
pendingDisambiguation: null, pendingDisambiguation: null,
pendingConfirmation: null,
transcript: [], transcript: [],
endedWith: null, endedWith: null,
...overrides, ...overrides,
+8
View File
@@ -21,6 +21,7 @@ export interface NounRef {
} }
export type ParsedCommand = export type ParsedCommand =
| { kind: 'confirmation'; confirmed: boolean }
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' | 'listen' } | { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' | 'listen' }
| { kind: 'verb-target'; verb: Verb; target: NounRef } | { kind: 'verb-target'; verb: Verb; target: NounRef }
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef } | { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
@@ -51,6 +52,11 @@ export interface PendingDisambiguation {
prompt: string prompt: string
} }
export interface PendingConfirmation {
command: ParsedCommand
prompt: string
}
export interface GameState { export interface GameState {
schemaVersion: number schemaVersion: number
location: RoomId location: RoomId
@@ -66,6 +72,8 @@ export interface GameState {
lastNoun: NounRef | null lastNoun: NounRef | null
/** Pending multi-word disambiguation, set when the parser cannot decide. */ /** Pending multi-word disambiguation, set when the parser cannot decide. */
pendingDisambiguation: PendingDisambiguation | null pendingDisambiguation: PendingDisambiguation | null
/** Pending confirmation for dangerous/game-changing commands. */
pendingConfirmation: PendingConfirmation | null
/** Capped at 200 entries; older entries are dropped on append. */ /** Capped at 200 entries; older entries are dropped on append. */
transcript: TranscriptLine[] transcript: TranscriptLine[]
/** Set true when the player has reached an ending. UI shows ending screen. */ /** Set true when the player has reached an ending. UI shows ending screen. */
+15
View File
@@ -48,4 +48,19 @@ describe('computeChips — sample world', () => {
expect(chips.find((c) => c.command === 'wait')).toBeTruthy() expect(chips.find((c) => c.command === 'wait')).toBeTruthy()
expect(chips.find((c) => c.command === 'help')).toBeTruthy() expect(chips.find((c) => c.command === 'help')).toBeTruthy()
}) })
it('shows only confirmation chips while a dangerous command is pending', () => {
const s = {
...initialStateFor(world),
pendingConfirmation: {
command: { kind: 'verb-target' as const, verb: 'attack' as const, target: { canonical: 'rat', raw: 'rat' } },
prompt: 'Are you sure?',
},
}
const chips = computeChips(s, world)
expect(chips).toEqual([
{ kind: 'meta', label: 'YES', command: 'yes', disabled: false },
{ kind: 'meta', label: 'NO', command: 'no', disabled: false },
])
})
}) })
+7
View File
@@ -20,6 +20,13 @@ export function computeChips(state: GameState, world: World): Chip[] {
const room = world.rooms[state.location] const room = world.rooms[state.location]
if (!room) return out if (!room) return out
if (state.pendingConfirmation) {
return [
{ kind: 'meta', label: 'YES', command: 'yes', disabled: false },
{ kind: 'meta', label: 'NO', command: 'no', disabled: false },
]
}
// Direction chips: enabled if exit exists, dimmed otherwise. // Direction chips: enabled if exit exists, dimmed otherwise.
const dirs: Direction[] = ['n', 's', 'e', 'w', 'u', 'd'] const dirs: Direction[] = ['n', 's', 'e', 'w', 'u', 'd']
for (const d of dirs) { for (const d of dirs) {
+11
View File
@@ -204,6 +204,7 @@ body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-anchor: none;
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-color: var(--m-dim) var(--m-bg); scrollbar-color: var(--m-dim) var(--m-bg);
scrollbar-width: thin; scrollbar-width: thin;
@@ -239,6 +240,16 @@ body {
margin-top: 0.6em; margin-top: 0.6em;
} }
.mystery-transcript .room-title {
scroll-margin-top: 0;
}
.mystery-transcript .room-scroll-spacer {
max-width: none;
margin: 0;
pointer-events: none;
}
.mystery-transcript .ascii-art { .mystery-transcript .ascii-art {
max-width: 100%; max-width: 100%;
overflow-x: auto; overflow-x: auto;
+138 -23
View File
@@ -44,6 +44,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
let historyIndex: number | null = null let historyIndex: number | null = null
let historyDraft = '' let historyDraft = ''
let idleHintTimer: number | null = null let idleHintTimer: number | null = null
let renderQueue: Promise<void> = Promise.resolve()
let renderGeneration = 0
let roomScrollSpacer: HTMLDivElement | null = null
const TYPE_INTERVAL_MS = 8
const TYPE_CHARS_PER_TICK = 3
const ROOM_SCROLL_MS = 180
const syncLightMeter = (): void => { const syncLightMeter = (): void => {
if (!lightMeterEl) return if (!lightMeterEl) return
@@ -152,18 +159,123 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
} }
} }
const renderAll = (lines: TranscriptLine[]): void => { const wait = (ms: number): Promise<void> =>
if (!transcriptEl) return new Promise((resolve) => {
for (const line of lines) { window.setTimeout(resolve, ms)
const el = document.createElement('div') })
el.className = line.kind
if (line.kind === 'system' && line.text.includes('|_| |_|')) { const isAsciiArtLine = (line: TranscriptLine): boolean =>
el.classList.add('ascii-art') line.kind === 'system' && line.text.includes('|_| |_|')
const isRoomTitleLine = (line: TranscriptLine): boolean => {
if (line.kind !== 'system') return false
const trimmed = line.text.trim()
return /^\[\s*.+\s*\]$/.test(trimmed) && !trimmed.includes('|')
}
const typeLine = async (el: HTMLDivElement, text: string): Promise<void> => {
el.textContent = ''
for (let i = TYPE_CHARS_PER_TICK; i < text.length; i += TYPE_CHARS_PER_TICK) {
el.textContent = text.slice(0, i)
await wait(TYPE_INTERVAL_MS)
}
el.textContent = text
}
const scrollToTopOf = async (el: HTMLElement): Promise<void> => {
if (!transcriptEl) return
transcriptEl.scrollTo({ top: Math.max(0, el.offsetTop), behavior: 'smooth' })
await wait(ROOM_SCROLL_MS)
}
const updateRoomScrollSpacer = (anchor?: HTMLElement): void => {
if (!transcriptEl) return
if (!roomScrollSpacer) {
roomScrollSpacer = document.createElement('div')
roomScrollSpacer.className = 'room-scroll-spacer'
roomScrollSpacer.setAttribute('aria-hidden', 'true')
}
const spacerHeight = Math.max(0, transcriptEl.clientHeight - (anchor?.offsetHeight ?? 0))
roomScrollSpacer.style.height = `${spacerHeight}px`
if (roomScrollSpacer.parentElement !== transcriptEl) {
transcriptEl.appendChild(roomScrollSpacer)
}
}
const scrollToContentBottom = (): void => {
if (!transcriptEl) return
updateRoomScrollSpacer()
const contentBottom = roomScrollSpacer?.offsetTop ?? transcriptEl.scrollHeight
transcriptEl.scrollTop = Math.max(0, contentBottom - transcriptEl.clientHeight)
}
const appendTranscriptElement = (el: HTMLDivElement): void => {
if (!transcriptEl) return
updateRoomScrollSpacer()
if (roomScrollSpacer?.parentElement === transcriptEl) {
transcriptEl.insertBefore(el, roomScrollSpacer)
return
} }
el.textContent = line.text
transcriptEl.appendChild(el) transcriptEl.appendChild(el)
} }
transcriptEl.scrollTop = transcriptEl.scrollHeight
const renderLines = async (
lines: TranscriptLine[],
animate: boolean,
shouldScroll: boolean,
generation: number,
): Promise<void> => {
if (!transcriptEl) return
const roomTitleInBatch = shouldScroll && animate && lines.some(isRoomTitleLine)
let roomTitleEl: HTMLDivElement | null = null
for (const line of lines) {
if (generation !== renderGeneration) return
const el = document.createElement('div')
el.className = line.kind
const asciiArt = isAsciiArtLine(line)
const roomTitle = roomTitleInBatch && isRoomTitleLine(line)
if (asciiArt) {
el.classList.add('ascii-art')
}
if (isRoomTitleLine(line)) {
el.classList.add('room-title')
}
const shouldType = animate && line.kind !== 'player' && !asciiArt && !roomTitle
el.textContent = shouldType ? '' : line.text
appendTranscriptElement(el)
if (roomTitle) {
roomTitleEl = el
updateRoomScrollSpacer(el)
await scrollToTopOf(el)
if (generation !== renderGeneration) return
}
if (shouldType) {
await typeLine(el, line.text)
if (generation !== renderGeneration) return
}
if (shouldScroll && !roomTitleInBatch) {
scrollToContentBottom()
}
}
if (shouldScroll && !roomTitleEl) {
scrollToContentBottom()
}
}
const renderAll = (lines: TranscriptLine[], options: { animate?: boolean; scroll?: boolean } = {}): void => {
const animate = options.animate ?? true
const shouldScroll = options.scroll ?? true
const generation = renderGeneration
renderQueue = renderQueue.then(() => renderLines(lines, animate, shouldScroll, generation)).catch((err) => {
console.error('[halfstreet] render error', err)
})
} }
const clearIdleHint = (): void => { const clearIdleHint = (): void => {
@@ -211,9 +323,9 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
text.className = 'mystery-help-body' text.className = 'mystery-help-body'
text.textContent = HELP_TEXT text.textContent = HELP_TEXT
el.append(close, text) el.append(close, text)
transcriptEl.appendChild(el) appendTranscriptElement(el)
transientHelpEl = el transientHelpEl = el
transcriptEl.scrollTop = transcriptEl.scrollHeight scrollToContentBottom()
} }
document.addEventListener('pointerdown', (e) => { document.addEventListener('pointerdown', (e) => {
@@ -234,30 +346,32 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
// notices). Pushes into state.transcript so they survive reload, then renders. // notices). Pushes into state.transcript so they survive reload, then renders.
// Engine-originated lines (from dispatch) are already in state.transcript; // Engine-originated lines (from dispatch) are already in state.transcript;
// those use renderAll directly. // those use renderAll directly.
const appendLines = (lines: TranscriptLine[]): void => { const appendLines = (lines: TranscriptLine[], options: { animate?: boolean; scroll?: boolean } = {}): void => {
state = { ...state, transcript: [...state.transcript, ...lines].slice(-TRANSCRIPT_CAP) } state = { ...state, transcript: [...state.transcript, ...lines].slice(-TRANSCRIPT_CAP) }
renderAll(lines) renderAll(lines, options)
} }
const restart = (): void => { const restart = (): void => {
const confirmed = confirm('Restart? Your progress will be lost.') const confirmed = confirm('Restart? Your progress will be lost.')
if (!confirmed) { if (!confirmed) {
appendLines([{ kind: 'system', text: '(restart cancelled)' }]) appendLines([{ kind: 'system', text: '(restart cancelled)' }], { scroll: false })
return return
} }
clearSave() clearSave()
state = initialStateFor(world) state = initialStateFor(world)
renderGeneration += 1
renderQueue = Promise.resolve()
transcriptEl.innerHTML = '' transcriptEl.innerHTML = ''
inputEl.value = '' inputEl.value = ''
syncCommandLine() syncCommandLine()
renderAll(state.transcript) renderAll(state.transcript, { animate: false })
saveState(state) saveState(state)
refreshChips() refreshChips()
syncLightMeter() syncLightMeter()
syncEndedUI() syncEndedUI()
} }
renderAll(state.transcript) renderAll(state.transcript, { animate: false })
refreshChips() refreshChips()
syncLightMeter() syncLightMeter()
syncEndedUI() syncEndedUI()
@@ -293,13 +407,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
commandHistory = [...commandHistory, raw].slice(-50) commandHistory = [...commandHistory, raw].slice(-50)
historyIndex = null historyIndex = null
historyDraft = '' historyDraft = ''
appendLines([{ kind: 'player', text: raw }]) appendLines([{ kind: 'player', text: raw }], { scroll: false })
// Once the game has ended, only restart and undo are allowed. // Once the game has ended, only restart and undo are allowed.
if (state.endedWith !== null) { if (state.endedWith !== null) {
const lower = raw.trim().toLowerCase() const lower = raw.trim().toLowerCase()
if (lower !== 'restart' && lower !== 'undo') { if (lower !== 'restart' && lower !== 'undo') {
appendLines([{ kind: 'system', text: 'The story has ended. Type `restart` or `undo`.' }]) appendLines([{ kind: 'system', text: 'The story has ended. Type `restart` or `undo`.' }], { scroll: false })
return return
} }
} }
@@ -318,13 +432,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
if (lastState) { if (lastState) {
state = lastState state = lastState
lastState = null lastState = null
appendLines([{ kind: 'system', text: '(undone)' }]) appendLines([{ kind: 'system', text: '(undone)' }], { scroll: false })
saveState(state) saveState(state)
refreshChips() refreshChips()
syncLightMeter() syncLightMeter()
syncEndedUI() syncEndedUI()
} else { } else {
appendLines([{ kind: 'system', text: 'There is no further back.' }]) appendLines([{ kind: 'system', text: 'There is no further back.' }], { scroll: false })
} }
return return
} }
@@ -339,11 +453,12 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
const ctx = buildParserContext(state) const ctx = buildParserContext(state)
const command = parse(raw, ctx) const command = parse(raw, ctx)
lastState = state lastState = state
const previousLocation = state.location
const result = dispatch(state, command, world) const result = dispatch(state, command, world)
state = result.state state = result.state
renderAll(result.appended) // dispatch already pushed these into state.transcript const shouldScrollToRoom = command.kind === 'go' && state.location !== previousLocation
renderAll(result.appended, { scroll: shouldScrollToRoom }) // dispatch already pushed these into state.transcript
saveState(state) saveState(state)
transcriptEl.scrollTop = transcriptEl.scrollHeight
if (raw.trim().toLowerCase() === 'theme') { if (raw.trim().toLowerCase() === 'theme') {
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme')) document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
} }
@@ -352,7 +467,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
syncEndedUI() syncEndedUI()
} catch (err) { } catch (err) {
console.error('[halfstreet] dispatch error', err) console.error('[halfstreet] dispatch error', err)
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }]) appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }], { scroll: false })
} }
}) })
+3 -2
View File
@@ -24,7 +24,7 @@
- [x] If the user says "light match" or "light match" the response should be "use match with what?" - [x] If the user says "light match" or "light match" the response should be "use match with what?"
- [x] If the user says "use match with letter" they should burn the letter. - [x] If the user says "use match with letter" they should burn the letter.
- [x] There should be a lighter in the smoking room that allows unlimited lighting. - [x] There should be a lighter in the smoking room that allows unlimited lighting.
- [ ] Create a mechanic that asks "Are you sure?" before taking critical actions like attacking or other game-changing mechanics that might affect the final ending. - [x] Create a mechanic that asks "Are you sure?" before taking critical actions like attacking or other game-changing mechanics that might affect the final ending.
- [ ] Add lightened descriptions to darkened rooms. About half the rooms should be too dark to see anything (affects ability to move forward, can't see exits or encounters, except for maybe hints at the encounters, like sounds or shapes in the dark) Add frontmatter property to all rooms: (dark: true/false). Make text in darkened rooms a grey color. - [ ] Add lightened descriptions to darkened rooms. About half the rooms should be too dark to see anything (affects ability to move forward, can't see exits or encounters, except for maybe hints at the encounters, like sounds or shapes in the dark) Add frontmatter property to all rooms: (dark: true/false). Make text in darkened rooms a grey color.
- [ ] Implement a simple "stealth mechanic", where sometimes it's advantageous to have the light out. - [ ] Implement a simple "stealth mechanic", where sometimes it's advantageous to have the light out.
- [ ] Implement a simple (optional?) minimap in the UI? - Maybe tied to an item? Once you get the map the minimap appears? Can we POC it? - [ ] Implement a simple (optional?) minimap in the UI? - Maybe tied to an item? Once you get the map the minimap appears? Can we POC it?
@@ -44,4 +44,5 @@
- [ ] Add item Whiskey bottle, half full of something smoky. In the kitchen. Drinking it gets the player drunk, which causes them to unlock the drunk rooms, which are a series of rooms where they can go many directions, but somehow end up in seemingly back in the same spot. They are essentially a maze of doors and hallways, ladders and levels. There should be boundaries established though, it shouldn't be endless. After 20 or so moves the player passes out and wakes up back in the foyer, with the whiskey bottle returned to the kitchen, somehow still half full. Add a random encounter in the dark rooms, a creaking floorboard helps you find a secret door that opens with a faceless man inside who gives you major plot info. After this encounter you pass out and wake up in the lobby as before. - [ ] Add item Whiskey bottle, half full of something smoky. In the kitchen. Drinking it gets the player drunk, which causes them to unlock the drunk rooms, which are a series of rooms where they can go many directions, but somehow end up in seemingly back in the same spot. They are essentially a maze of doors and hallways, ladders and levels. There should be boundaries established though, it shouldn't be endless. After 20 or so moves the player passes out and wakes up back in the foyer, with the whiskey bottle returned to the kitchen, somehow still half full. Add a random encounter in the dark rooms, a creaking floorboard helps you find a secret door that opens with a faceless man inside who gives you major plot info. After this encounter you pass out and wake up in the lobby as before.
- [ ] Set up BugPin as a self-hosted visual bug reporter for the site, then have incoming reports create markdown files under `src/world/bugs/` via a webhook or API bridge so bugs can be tracked in git alongside the game content. Include screenshot/annotation metadata in the markdown and decide whether these bug docs stay outside the world loader or get their own loader later. - [ ] Set up BugPin as a self-hosted visual bug reporter for the site, then have incoming reports create markdown files under `src/world/bugs/` via a webhook or API bridge so bugs can be tracked in git alongside the game content. Include screenshot/annotation metadata in the markdown and decide whether these bug docs stay outside the world loader or get their own loader later.
- [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate. - [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate.
- [x] FEATURE: Add a short "typed" effect to the text. Make it look like it's being typed out, if that makes sense, one character at a time. The effect should be brief.
- [x] FEATURE: Whenever you change rooms, scroll the text so the name of the room you're in is at the top. Users can scroll up to see the history. This should be an effect where the old text slides up to make room for the new text, and this should happen before the "typed" effect.