This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="m1043.8 831.94-85.5-483.94c-9.5625-53.719-56.25-92.859-110.81-92.812h-33.938c-9.6094-32.719-41.062-54-75-50.812l-1.125-37.5c-1.6406-51.656-44.297-92.531-96-91.875h-82.875c-51.516-0.23438-93.844 40.734-95.25 92.25l-1.125 37.5c-33.891-3.0469-65.25 18.188-75 50.812h-34.688c-54.562-0.046875-101.25 39.094-110.81 92.812l-85.5 483.56c-15.609 88.219 40.453 173.26 127.69 193.69l67.688 15.938v27.188c0 31.078 25.172 56.25 56.25 56.25h383.63c31.078 0 56.25-25.172 56.25-56.25v-27.188l67.688-15.938c87.516-20.109 144-105.23 128.44-193.69zm-355.13 81.75 18.75 56.25h-215.06l18.75-56.25zm6.9375-37.5h-76.875v-63.188c33.938-6.6094 62.391-29.672 75.844-61.547 13.5-31.828 10.219-68.297-8.7188-97.266l-53.812-80.812c-6.7969-11.203-18.938-18.047-32.062-18.047s-25.266 6.8438-32.062 18.047l-53.812 80.812c-18.938 28.969-22.219 65.438-8.7188 97.266 13.453 31.875 41.906 54.938 75.844 61.547v63.188h-76.875l-70.312-53.812c-33.797-25.969-58.312-62.203-69.844-103.27s-9.4219-84.75 5.9062-124.55l98.625-251.26h264.37l97.688 251.26c15.328 39.797 17.438 83.484 5.9062 124.55s-36.047 77.297-69.844 103.27zm-194.81-707.44c0.70312-31.359 26.391-56.344 57.75-56.25h82.875c31.359-0.09375 57.047 24.891 57.75 56.25l1.125 36h-200.63zm-208.31 820.31c-67.594-15.844-111.14-81.609-99.375-150l85.5-483.94c6.375-35.812 37.5-61.875 73.875-61.875h33.938c6.0469 21.375 22.031 38.578 42.938 46.125l-94.312 241.87c-18.188 47.391-20.578 99.375-6.8438 148.26 13.688 48.891 42.797 92.016 82.969 123.05l64.688 49.5-23.062 67.875h-44.25c-22.641 0.046875-43.125 13.406-52.312 34.125zm615 0-63.75 15c-9.0469-20.859-29.578-34.359-52.312-34.5h-44.25l-23.062-67.875 64.688-49.5c40.078-30.984 69.141-74.109 82.875-122.9 13.688-48.75 11.344-100.69-6.75-148.03 0 0-93.75-242.26-93.75-242.06 20.812-7.6406 36.703-24.797 42.75-46.125h33.562c36.375 0 67.5 26.062 73.875 61.875l85.5 483.56c12.094 68.625-31.547 134.72-99.375 150.56z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -193,6 +193,21 @@ describe('dispatcher — take and drop', () => {
|
|||||||
const r = dispatch(s, { kind: 'verb-target', verb: 'drop', target: { canonical: 'torch', raw: 'torch' } }, world)
|
const r = dispatch(s, { kind: 'verb-target', verb: 'drop', target: { canonical: 'torch', raw: 'torch' } }, world)
|
||||||
expect(r.state.inventory.find((i) => i.id === 'torch')).toBeUndefined()
|
expect(r.state.inventory.find((i) => i.id === 'torch')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('refuses to drop a lit lightable item', () => {
|
||||||
|
const lightWorld: World = {
|
||||||
|
...world,
|
||||||
|
items: {
|
||||||
|
...world.items,
|
||||||
|
lamp: { id: 'lamp', names: ['lamp'], short: 'an oil lamp', long: 'An oil lamp.', initialState: { lit: false }, takeable: true, lightable: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let s = initialStateFor(lightWorld)
|
||||||
|
s = { ...s, inventory: [{ id: 'lamp', state: { lit: true, burn: 6 } }] }
|
||||||
|
const r = dispatch(s, { kind: 'verb-target', verb: 'drop', target: { canonical: 'lamp', raw: 'lamp' } }, lightWorld)
|
||||||
|
expect(r.appended.at(-1)?.text).toBe('Extinguish it first.')
|
||||||
|
expect(r.state.inventory.find((i) => i.id === 'lamp')).toBeDefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('dispatcher — examine', () => {
|
describe('dispatcher — examine', () => {
|
||||||
@@ -414,6 +429,48 @@ describe('light/extinguish verbs (implicit lighter)', () => {
|
|||||||
expect(texts).toContain('The book is empty.')
|
expect(texts).toContain('The book is empty.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('burns two segments on wait and extinguishes on the third wait', () => {
|
||||||
|
const world = w()
|
||||||
|
let state = initialStateFor(world)
|
||||||
|
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 6 } }] }
|
||||||
|
|
||||||
|
const first = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(4)
|
||||||
|
expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true)
|
||||||
|
|
||||||
|
const second = dispatch(first.state, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(second.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(2)
|
||||||
|
|
||||||
|
const third = dispatch(second.state, { kind: 'verb-only', verb: 'wait' }, world)
|
||||||
|
expect(third.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(0)
|
||||||
|
expect(third.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false)
|
||||||
|
expect(third.appended.map((l) => l.text)).toContain('The flame dies.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('burns one segment on movement', () => {
|
||||||
|
const world = w()
|
||||||
|
const movingWorld: World = {
|
||||||
|
...world,
|
||||||
|
rooms: {
|
||||||
|
...world.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(movingWorld)
|
||||||
|
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 6 } }] }
|
||||||
|
|
||||||
|
const result = dispatch(state, { kind: 'go', direction: 'n' }, movingWorld)
|
||||||
|
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(5)
|
||||||
|
expect(result.state.location).toBe('r2')
|
||||||
|
})
|
||||||
|
|
||||||
it('extinguishes a lit lamp', () => {
|
it('extinguishes a lit lamp', () => {
|
||||||
const world = w()
|
const world = w()
|
||||||
let state = initialStateFor(world)
|
let state = initialStateFor(world)
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ import type { GameState, ParsedCommand, DispatchResult, ItemInstance, Transcript
|
|||||||
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
|
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
|
||||||
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
|
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
|
||||||
|
|
||||||
|
export const LIGHT_TURNS_MAX = 6
|
||||||
|
|
||||||
|
export interface LightStatus {
|
||||||
|
itemId: string
|
||||||
|
lit: boolean
|
||||||
|
turnsLeft: number
|
||||||
|
maxTurns: number
|
||||||
|
}
|
||||||
|
|
||||||
const HALFSTREET_ASCII = String.raw`
|
const HALFSTREET_ASCII = String.raw`
|
||||||
_ _ _ __ ____ _ _
|
_ _ _ __ ____ _ _
|
||||||
| | | | __ _| |/ _| / ___|| |_ _ __ ___ ___| |_
|
| | | | __ _| |/ _| / ___|| |_ _ __ ___ ___| |_
|
||||||
@@ -42,6 +51,22 @@ export function initialStateFor(world: World): GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLightStatus(state: GameState, world: World): LightStatus | null {
|
||||||
|
for (const inst of state.inventory) {
|
||||||
|
const def = world.items[inst.id]
|
||||||
|
if (!def?.lightable) continue
|
||||||
|
if (inst.state['lit'] !== true) continue
|
||||||
|
const turnsLeft = getLightTurnsLeft(inst)
|
||||||
|
return {
|
||||||
|
itemId: inst.id,
|
||||||
|
lit: true,
|
||||||
|
turnsLeft,
|
||||||
|
maxTurns: LIGHT_TURNS_MAX,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function append(state: GameState, lines: TranscriptLine[]): GameState {
|
function append(state: GameState, lines: TranscriptLine[]): GameState {
|
||||||
const transcript = [...state.transcript, ...lines]
|
const transcript = [...state.transcript, ...lines]
|
||||||
return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) }
|
return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) }
|
||||||
@@ -149,7 +174,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
|||||||
}
|
}
|
||||||
if (command.verb === 'look') return withEndingCheck(handleLook(state, 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 === 'inventory') return withEndingCheck(handleInventory(state, world), world)
|
||||||
if (command.verb === 'wait') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'Time passes.' }]), world)
|
if (command.verb === 'wait') return withEndingCheck(handleWait(state, world), world)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'verb-target') {
|
if (command.kind === 'verb-target') {
|
||||||
@@ -247,9 +272,13 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
|
|||||||
if (idx > 0) next = { ...next, resolveLevel: ladder[idx - 1]! }
|
if (idx > 0) next = { ...next, resolveLevel: ladder[idx - 1]! }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lightTick = advanceLightState(next, 1, world)
|
||||||
|
next = lightTick.state
|
||||||
|
|
||||||
const arrivalLines: TranscriptLine[] = [
|
const arrivalLines: TranscriptLine[] = [
|
||||||
{ kind: 'system', text: destRoom.title },
|
{ kind: 'system', text: destRoom.title },
|
||||||
{ kind: 'narration', text: description },
|
{ kind: 'narration', text: description },
|
||||||
|
...lightTick.lines,
|
||||||
]
|
]
|
||||||
const result = narrate(next, arrivalLines)
|
const result = narrate(next, arrivalLines)
|
||||||
|
|
||||||
@@ -261,6 +290,14 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleWait(state: GameState, world: World): DispatchResult {
|
||||||
|
const lightTick = advanceLightState(state, 2, world)
|
||||||
|
return narrate(lightTick.state, [
|
||||||
|
{ kind: 'narration', text: 'Time passes.' },
|
||||||
|
...lightTick.lines,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
function handleLook(state: GameState, world: World): DispatchResult {
|
function handleLook(state: GameState, world: World): DispatchResult {
|
||||||
const room = world.rooms[state.location]
|
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: 'You see nothing.' }])
|
||||||
@@ -337,6 +374,11 @@ function handleDrop(state: GameState, itemId: string, _world: World): DispatchRe
|
|||||||
if (!state.inventory.find((i) => i.id === itemId)) {
|
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: 'You don\'t have that.' }])
|
||||||
}
|
}
|
||||||
|
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." }])
|
||||||
|
}
|
||||||
let next: GameState = {
|
let next: GameState = {
|
||||||
...state,
|
...state,
|
||||||
inventory: state.inventory.filter((i) => i.id !== itemId),
|
inventory: state.inventory.filter((i) => i.id !== itemId),
|
||||||
@@ -431,7 +473,7 @@ function handleLight(state: GameState, targetId: string, instrumentId: string |
|
|||||||
const lighterUsesField = typeof lighterInst.state['uses'] === 'number' ? lighterInst.state['uses'] : null
|
const lighterUsesField = typeof lighterInst.state['uses'] === 'number' ? lighterInst.state['uses'] : null
|
||||||
const newLighterUses = lighterUsesField === null ? null : lighterUsesField - 1
|
const newLighterUses = lighterUsesField === null ? null : lighterUsesField - 1
|
||||||
const newInventory = state.inventory.map((i) => {
|
const newInventory = state.inventory.map((i) => {
|
||||||
if (i.id === targetInst.id) return { ...i, state: { ...i.state, lit: true } }
|
if (i.id === targetInst.id) return { ...i, state: { ...i.state, lit: true, burn: LIGHT_TURNS_MAX } }
|
||||||
if (i.id === lighterInst!.id && newLighterUses !== null) return { ...i, state: { ...i.state, uses: newLighterUses } }
|
if (i.id === lighterInst!.id && newLighterUses !== null) return { ...i, state: { ...i.state, uses: newLighterUses } }
|
||||||
return i
|
return i
|
||||||
})
|
})
|
||||||
@@ -506,7 +548,36 @@ function handleExtinguish(state: GameState, targetId: string, world: World): Dis
|
|||||||
return narrate(state, [{ kind: 'narration', text: "It isn't lit." }])
|
return narrate(state, [{ kind: 'narration', text: "It isn't lit." }])
|
||||||
}
|
}
|
||||||
const newInventory = state.inventory.map((i) =>
|
const newInventory = state.inventory.map((i) =>
|
||||||
i.id === targetId ? { ...i, state: { ...i.state, lit: false } } : i,
|
i.id === targetId ? { ...i, state: { ...i.state, lit: false, 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 ?? 'The flame dies.' }])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function advanceLightState(state: GameState, cost: number, world: World): { state: GameState; lines: TranscriptLine[] } {
|
||||||
|
if (cost <= 0) 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
|
||||||
|
|
||||||
|
const turnsLeft = getLightTurnsLeft(inst)
|
||||||
|
const nextTurns = Math.max(0, turnsLeft - cost)
|
||||||
|
changed = true
|
||||||
|
|
||||||
|
if (nextTurns === 0) {
|
||||||
|
lines.push({ kind: 'narration', text: def.extinguishedText ?? 'The flame dies.' })
|
||||||
|
return { ...inst, state: { ...inst.state, lit: false, burn: 0 } }
|
||||||
|
}
|
||||||
|
return { ...inst, state: { ...inst.state, burn: nextTurns } }
|
||||||
|
})
|
||||||
|
|
||||||
|
return changed ? { state: { ...state, inventory }, lines } : { state, lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLightTurnsLeft(inst: ItemInstance): number {
|
||||||
|
const turns = inst.state['burn']
|
||||||
|
if (typeof turns === 'number') return Math.max(0, turns)
|
||||||
|
return inst.state['lit'] === true ? LIGHT_TURNS_MAX : 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ import '../ui/crt.css'
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mystery-transcript" data-mystery-transcript aria-live="polite" aria-atomic="false"></div>
|
<div class="mystery-transcript" data-mystery-transcript aria-live="polite" aria-atomic="false"></div>
|
||||||
|
<div class="mystery-controls">
|
||||||
<div class="mystery-chips" data-mystery-chips></div>
|
<div class="mystery-chips" data-mystery-chips></div>
|
||||||
|
<div class="mystery-light-meter" data-mystery-light-meter aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
<div class="mystery-input-row">
|
<div class="mystery-input-row">
|
||||||
<input
|
<input
|
||||||
class="mystery-input"
|
class="mystery-input"
|
||||||
|
|||||||
+106
-3
@@ -229,6 +229,10 @@ body {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mystery-transcript > div {
|
||||||
|
max-width: 80ch;
|
||||||
|
}
|
||||||
|
|
||||||
.mystery-transcript .system {
|
.mystery-transcript .system {
|
||||||
color: var(--m-accent-1);
|
color: var(--m-accent-1);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -247,6 +251,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mystery-transcript .help {
|
.mystery-transcript .help {
|
||||||
|
position: relative;
|
||||||
color: var(--m-fg);
|
color: var(--m-fg);
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
border: 1px var(--m-divider-style) var(--m-dim);
|
border: 1px var(--m-divider-style) var(--m-dim);
|
||||||
@@ -254,6 +259,34 @@ body {
|
|||||||
margin: 0.75em 0;
|
margin: 0.75em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mystery-transcript .help .mystery-help-body {
|
||||||
|
padding-right: 3ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript .help .mystery-help-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--m-fg);
|
||||||
|
border: 1px solid var(--m-dim);
|
||||||
|
border-radius: 2px;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-transcript .help .mystery-help-close:hover,
|
||||||
|
.mystery-transcript .help .mystery-help-close:focus-visible {
|
||||||
|
border-color: var(--m-fg);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mystery-transcript .player {
|
.mystery-transcript .player {
|
||||||
color: var(--m-accent-2);
|
color: var(--m-accent-2);
|
||||||
}
|
}
|
||||||
@@ -331,14 +364,71 @@ body {
|
|||||||
50%, 100% { opacity: 0; }
|
50%, 100% { opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mystery-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.mystery-chips {
|
.mystery-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px 0 4px;
|
padding: 6px 0 4px;
|
||||||
position: relative;
|
min-width: 0;
|
||||||
z-index: 2;
|
flex: 1 1 auto;
|
||||||
margin-top: 8px;
|
}
|
||||||
|
|
||||||
|
.mystery-light-meter {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 98px;
|
||||||
|
min-height: 58px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--m-fg);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-light-meter[data-lit='true'] {
|
||||||
|
color: var(--m-fg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-light-icon {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 0 2px currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-light-leds {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-light-segment {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--m-dim);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15) inset;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-light-segment[data-segment-state='lit'] {
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 7px currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mystery-chip {
|
.mystery-chip {
|
||||||
@@ -383,6 +473,19 @@ body {
|
|||||||
.mystery-chip {
|
.mystery-chip {
|
||||||
padding: 4px 7px;
|
padding: 4px 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mystery-controls {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-light-meter {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mystery-light-icon {
|
||||||
|
width: 27px;
|
||||||
|
height: 27px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mystery-input].ended {
|
[data-mystery-input].ended {
|
||||||
|
|||||||
+79
-3
@@ -1,6 +1,6 @@
|
|||||||
import { parse } from '../engine/parser'
|
import { parse } from '../engine/parser'
|
||||||
import type { ParserContext } from '../engine/parser'
|
import type { ParserContext } from '../engine/parser'
|
||||||
import { dispatch, initialStateFor, getItemsInRoom } from '../engine/dispatcher'
|
import { dispatch, initialStateFor, getItemsInRoom, getLightStatus, LIGHT_TURNS_MAX } from '../engine/dispatcher'
|
||||||
import { saveState, loadState, clearSave } from '../engine/save'
|
import { saveState, loadState, clearSave } from '../engine/save'
|
||||||
import { world } from '../world'
|
import { world } from '../world'
|
||||||
import type { GameState, TranscriptLine } from '../engine/types'
|
import type { GameState, TranscriptLine } from '../engine/types'
|
||||||
@@ -11,6 +11,8 @@ import { renderChips } from './chip-render'
|
|||||||
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
|
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
|
||||||
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
|
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
|
||||||
const inputDisplayEl = document.querySelector<HTMLSpanElement>('[data-mystery-input-display]')
|
const inputDisplayEl = document.querySelector<HTMLSpanElement>('[data-mystery-input-display]')
|
||||||
|
const lightMeterEl = document.querySelector<HTMLDivElement>('[data-mystery-light-meter]')
|
||||||
|
const LIGHT_ICON_URL = new URL('../assets/noun-oil-lamp-8301660.svg', import.meta.url).href
|
||||||
|
|
||||||
const HELP_TEXT = `You arrive at the address, but you do not remember what has happened. The road behind you is gone...
|
const HELP_TEXT = `You arrive at the address, but you do not remember what has happened. The road behind you is gone...
|
||||||
|
|
||||||
@@ -43,6 +45,40 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
let historyDraft = ''
|
let historyDraft = ''
|
||||||
let idleHintTimer: number | null = null
|
let idleHintTimer: number | null = null
|
||||||
|
|
||||||
|
const syncLightMeter = (): void => {
|
||||||
|
if (!lightMeterEl) return
|
||||||
|
const status = getLightStatus(state, world)
|
||||||
|
lightMeterEl.hidden = !status
|
||||||
|
if (!status) {
|
||||||
|
lightMeterEl.innerHTML = ''
|
||||||
|
lightMeterEl.dataset['lit'] = 'false'
|
||||||
|
lightMeterEl.dataset['turnsLeft'] = '0'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lightMeterEl.innerHTML = ''
|
||||||
|
lightMeterEl.dataset['lit'] = 'true'
|
||||||
|
lightMeterEl.dataset['turnsLeft'] = String(status.turnsLeft)
|
||||||
|
|
||||||
|
const icon = document.createElement('img')
|
||||||
|
icon.className = 'mystery-light-icon'
|
||||||
|
icon.src = LIGHT_ICON_URL
|
||||||
|
icon.alt = ''
|
||||||
|
icon.setAttribute('aria-hidden', 'true')
|
||||||
|
lightMeterEl.appendChild(icon)
|
||||||
|
|
||||||
|
const leds = document.createElement('div')
|
||||||
|
leds.className = 'mystery-light-leds'
|
||||||
|
const turnsLeft = Math.max(0, Math.min(LIGHT_TURNS_MAX, status.turnsLeft))
|
||||||
|
for (let i = 0; i < LIGHT_TURNS_MAX; i++) {
|
||||||
|
const segment = document.createElement('span')
|
||||||
|
segment.className = 'mystery-light-segment'
|
||||||
|
segment.dataset['segmentState'] = i < turnsLeft ? 'lit' : 'dim'
|
||||||
|
leds.appendChild(segment)
|
||||||
|
}
|
||||||
|
lightMeterEl.appendChild(leds)
|
||||||
|
}
|
||||||
|
|
||||||
if (!restored) {
|
if (!restored) {
|
||||||
// Fresh state already includes the opening narration in its transcript.
|
// Fresh state already includes the opening narration in its transcript.
|
||||||
} else if (restored.transcript.length === 0) {
|
} else if (restored.transcript.length === 0) {
|
||||||
@@ -150,13 +186,43 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
clearTransientHelp()
|
clearTransientHelp()
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
el.className = 'system help'
|
el.className = 'system help'
|
||||||
el.dataset.transientHelp = 'true'
|
el.dataset['transientHelp'] = 'true'
|
||||||
el.textContent = HELP_TEXT
|
|
||||||
|
const close = document.createElement('button')
|
||||||
|
close.type = 'button'
|
||||||
|
close.className = 'mystery-help-close'
|
||||||
|
close.dataset['helpClose'] = 'true'
|
||||||
|
close.setAttribute('aria-label', 'Close help')
|
||||||
|
close.textContent = 'x'
|
||||||
|
close.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
clearTransientHelp()
|
||||||
|
return
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = document.createElement('div')
|
||||||
|
text.className = 'mystery-help-body'
|
||||||
|
text.textContent = HELP_TEXT
|
||||||
|
el.append(close, text)
|
||||||
transcriptEl.appendChild(el)
|
transcriptEl.appendChild(el)
|
||||||
transientHelpEl = el
|
transientHelpEl = el
|
||||||
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
transcriptEl.scrollTop = transcriptEl.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', (e) => {
|
||||||
|
if (!transientHelpEl) return
|
||||||
|
const target = e.target as Node | null
|
||||||
|
if (target && transientHelpEl.contains(target)) return
|
||||||
|
clearTransientHelp()
|
||||||
|
})
|
||||||
|
|
||||||
|
const hideHelpOnInput = (): void => {
|
||||||
|
if (!transientHelpEl) return
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (inputEl.value.trim().length > 0) clearTransientHelp()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// For UI-originated lines (player input, restart/undo/quit messages, error
|
// For UI-originated lines (player input, restart/undo/quit messages, error
|
||||||
// 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;
|
||||||
@@ -180,11 +246,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
renderAll(state.transcript)
|
renderAll(state.transcript)
|
||||||
saveState(state)
|
saveState(state)
|
||||||
refreshChips()
|
refreshChips()
|
||||||
|
syncLightMeter()
|
||||||
syncEndedUI()
|
syncEndedUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAll(state.transcript)
|
renderAll(state.transcript)
|
||||||
refreshChips()
|
refreshChips()
|
||||||
|
syncLightMeter()
|
||||||
syncEndedUI()
|
syncEndedUI()
|
||||||
syncCommandLine()
|
syncCommandLine()
|
||||||
scheduleIdleHint()
|
scheduleIdleHint()
|
||||||
@@ -246,6 +314,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
appendLines([{ kind: 'system', text: '(undone)' }])
|
appendLines([{ kind: 'system', text: '(undone)' }])
|
||||||
saveState(state)
|
saveState(state)
|
||||||
refreshChips()
|
refreshChips()
|
||||||
|
syncLightMeter()
|
||||||
syncEndedUI()
|
syncEndedUI()
|
||||||
} else {
|
} else {
|
||||||
appendLines([{ kind: 'system', text: 'There is no further back.' }])
|
appendLines([{ kind: 'system', text: 'There is no further back.' }])
|
||||||
@@ -272,6 +341,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
|
||||||
}
|
}
|
||||||
refreshChips()
|
refreshChips()
|
||||||
|
syncLightMeter()
|
||||||
syncEndedUI()
|
syncEndedUI()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[halfstreet] dispatch error', err)
|
console.error('[halfstreet] dispatch error', err)
|
||||||
@@ -301,10 +371,16 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
if (transientHelpEl) {
|
||||||
|
e.preventDefault()
|
||||||
|
clearTransientHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
saveState(state)
|
saveState(state)
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
document.addEventListener('halfstreet-restart', restart)
|
document.addEventListener('halfstreet-restart', restart)
|
||||||
|
inputEl.addEventListener('input', hideHelpOnInput)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-9
@@ -1,18 +1,12 @@
|
|||||||
- [x] Need to add help text when user types "help". This should give them some common commands and explain the concepts behind text adventure games. It should also include an exceprt at the beginning from the opening text. "You arrive at the address, but you do not remember what has happened. The road behind you is gone...". The help text should disappear after the user types a new prompt (i.e., it's not persistent).
|
- [x] Need to add help text when user types "help". This should give them some common commands and explain the concepts behind text adventure games. It should also include an exceprt at the beginning from the opening text. "You arrive at the address, but you do not remember what has happened. The road behind you is gone...". The help text should disappear after the user types a new prompt (i.e., it's not persistent).
|
||||||
- [x] Need to add the tiles from mobile to desktop view.
|
- [x] Need to add the tiles from mobile to desktop view.
|
||||||
- [x] Enhance tiles with contextual awareness, enabling tiles to appear in rooms when appropriate (e.g. "attack rat").
|
- [x] Enhance tiles with contextual awareness, enabling tiles to appear in rooms when appropriate (e.g. "attack rat").
|
||||||
- [ ] 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] Add a tile for USE
|
- [x] Add a tile for USE
|
||||||
- [ ] Add contextual awareness and autocomplete. For example a popup that appears above the USE text or when a user types "use". The user is able to autofill the rest of the thing by using the keyboard to toggle through the inv list and tab-complete the option, (or tap on mobile) "e.g. use (matches, light, letter) *on* (lamp)" - the word "on" there being suggested. Suggestions for autocomplete are in italics. This is one modern design element we're going to add.
|
|
||||||
- [x] Add a footer with "By [Ethan J Lewis](https://ethanjlewis.com) | [Source Code](https://half.st/ejlewis/halfstreet) | [GNU General Public License v3.0](https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE)"
|
- [x] Add a footer with "By [Ethan J Lewis](https://ethanjlewis.com) | [Source Code](https://half.st/ejlewis/halfstreet) | [GNU General Public License v3.0](https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE)"
|
||||||
- [x] Add "Half Street" as ASCII Art to the intro text.
|
- [x] Add "Half Street" as ASCII Art to the intro text.
|
||||||
- [x] Add logic to make the last sentence in the examined description conditional. This is where we'll list items in the room. (e.g., "The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. *The oil lamp is on the side table.*")
|
- [x] Add logic to make the last sentence in the examined description conditional. This is where we'll list items in the room. (e.g., "The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. *The oil lamp is on the side table.*")
|
||||||
- [ ] Create a new item with a mechanic: whiskey bottle. When the user drinks it they get drunk and are transported to the "drunk rooms" which are a bit of a maze and things get a little topsy-turvy. The player chances to lose an item (returning to its original spot) when they get drunk and wakes up several turns later somewhere else predetermined.
|
|
||||||
- [ ] 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 entounters, 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.
|
|
||||||
- [x] Fix mobile - scrolling issue (page grows as the terminal grows).
|
- [x] Fix mobile - scrolling issue (page grows as the terminal grows).
|
||||||
- [x] Fix mobile - ascii text art at beginning too big to render
|
- [x] Fix mobile - ascii text art at beginning too big to render
|
||||||
- [ ] 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?
|
|
||||||
- [x] Feature: Ability to retain console history, e.g., scroll through previous commands with up and down arrows.
|
- [x] Feature: Ability to retain console history, e.g., scroll through previous commands with up and down arrows.
|
||||||
- [x] Feature: Grey italicized "type here..." text that appears near the terminal if the user doesn't click into the terminal within 30 seconds of entering the game or click the help button. The text disappears once a user clicks in the terminal, or selects a card.
|
- [x] Feature: Grey italicized "type here..." text that appears near the terminal if the user doesn't click into the terminal within 30 seconds of entering the game or click the help button. The text disappears once a user clicks in the terminal, or selects a card.
|
||||||
- [x] Change footer to "Copyright (C) 2026 [Ethan J Lewis](https://ethanjlewis.com) | [GNU 3.0](https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE)| [Source Code](https://half.st/ejlewis/halfstreet)"
|
- [x] Change footer to "Copyright (C) 2026 [Ethan J Lewis](https://ethanjlewis.com) | [GNU 3.0](https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE)| [Source Code](https://half.st/ejlewis/halfstreet)"
|
||||||
@@ -27,9 +21,19 @@
|
|||||||
- [x] Feature: Add "Restart" option to option menu
|
- [x] Feature: Add "Restart" option to option menu
|
||||||
- [x] Bug: gear icon is still wayyyyyyy toooo smallllll it needs to be like 4x larger at least.
|
- [x] Bug: gear icon is still wayyyyyyy toooo smallllll it needs to be like 4x larger at least.
|
||||||
- [x] Add a "wait" tile.
|
- [x] Add a "wait" tile.
|
||||||
- [ ] Add a mechanic where after the player waits 3 times or moves six times the light goes out and needs to be relit.
|
|
||||||
- [ ] Add attack options for most encounters. Rarely this will be a good idea though.
|
|
||||||
- [ ] Add a failure condition for attacking at the wrong time. Make the reasons for the failure condition contextual, for example, when they attack the stair sleeper they might trip on the stair and get injured.
|
|
||||||
- [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.
|
||||||
|
- [ ] Create a new item with a mechanic: whiskey bottle. When the user drinks it they get drunk and are transported to the "drunk rooms" which are a bit of a maze and things get a little topsy-turvy. The player chances to lose an item (returning to its original spot) when they get drunk and wakes up several turns later somewhere else predetermined.
|
||||||
|
- [ ] 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 entounters, 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 (optional?) minimap in the UI? - Maybe tied to an item? Once you get the map the minimap appears? Can we POC it?
|
||||||
|
- [x] Add a mechanic where after the player waits 3 times or moves six times the light goes out and needs to be relit. Or something along those lines. We need a sense of time. Maybe some situations blow out the light
|
||||||
|
- [ ] Add attack options for most encounters. Rarely this will be a good idea though.
|
||||||
|
- [ ] Add a failure condition for attacking at the wrong time. Make the reasons for the failure condition contextual, for example, when they attack the stair sleeper they might trip on the stair and get injured.
|
||||||
|
- [ ] Add contextual awareness and autocomplete. For example a popup that appears above the USE text or when a user types "use". The user is able to autofill the rest of the thing by using the keyboard to toggle through the inv list and tab-complete the option, (or tap on mobile) "e.g. use (matches, light, letter) *on* (lamp)" - the word "on" there being suggested. Suggestions for autocomplete are in italics. This is one modern design element we're going to add.
|
||||||
|
- [ ] Add a Notebook function. Automatically make notes as the game progresses.
|
||||||
|
- [ ] Implement a carry mechanic. Decide whether we should have a limited carry ability (only able to carry a few things?) or we night need a full inventory system, where items are assigned to pockets or hand carry and we can only hand carry a couple of items?
|
||||||
|
- [ ] Implement a "drop" mechanic
|
||||||
|
- [x] We need a light indicator that shows when the light is lit and how much time is left on the light. Use the svg file I dropped in the src/assets folder for the indicator. The indicator should be a 6-segment led that runs in a dotted line underneath the light indicator and burns out right to left. The color of the indicator should be bright when it's lit and dim when it's not. The indicator should be to the right of the tiles and sized appropriately.
|
||||||
|
|||||||
@@ -51,6 +51,24 @@ describe('assembled world', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hallway prose names every enabled exit', () => {
|
||||||
|
const hallway = world.rooms['hallway']
|
||||||
|
expect(hallway).toBeDefined()
|
||||||
|
if (!hallway) throw new Error('hallway room is missing')
|
||||||
|
expect(hallway.exits).toEqual({
|
||||||
|
n: 'dining-room',
|
||||||
|
s: 'foyer',
|
||||||
|
e: 'cellar-stair',
|
||||||
|
w: 'smoking-room',
|
||||||
|
u: 'parlor',
|
||||||
|
d: 'music-room',
|
||||||
|
})
|
||||||
|
const prose = `${hallway.descriptions.firstVisit}\n${hallway.descriptions.examined}`.toLowerCase()
|
||||||
|
for (const word of ['north', 'south', 'east', 'west', 'up', 'down']) {
|
||||||
|
expect(prose, `hallway prose should mention ${word}`).toContain(word)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('all room item refs resolve to known items', () => {
|
it('all room item refs resolve to known items', () => {
|
||||||
for (const room of Object.values(world.rooms)) {
|
for (const room of Object.values(world.rooms)) {
|
||||||
for (const itemId of room.items) {
|
for (const itemId of room.items) {
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ encounter: null
|
|||||||
---
|
---
|
||||||
|
|
||||||
## first-visit
|
## first-visit
|
||||||
A long hallway, lit by nothing. It runs further than the house should allow. An iron oil lamp sits on a side table. The foyer is south. A stair descends east.
|
A long hallway, lit by nothing. It runs further than the house should allow. An iron oil lamp sits on a side table.
|
||||||
|
|
||||||
|
The foyer is south. A dining room waits north, a cellar stair descends east, and a smoking room opens west. Two wrong thresholds also present themselves: one climbing up toward a parlor, one dropping down toward music.
|
||||||
|
|
||||||
## revisit
|
## revisit
|
||||||
The long hallway. It has not shortened.
|
The long hallway. It has not shortened.
|
||||||
|
|
||||||
## examined
|
## examined
|
||||||
The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. Doors wait north and west. Two more thresholds sit where the wall should be solid.
|
The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. The foyer is south. Doors wait north and west. The cellar stair is east. Two more thresholds sit where the wall should be solid: up toward a parlor, and down toward music.
|
||||||
|
|||||||
Reference in New Issue
Block a user