fix: redesign RuntimeVariableDisplay as vertical stat rows

- Replace fragmented chip layout with full-width vertical rows
- Respect icon-only setting (show icon without displayName when icon is set)
- Text variables now show full text at full width (no truncation)
- Number+range progress bar is full width (not tiny w-16 inside chip)
- Remove border-t divider for cleaner integration with entity panels
- Include uncommitted runtime variable schema and store changes
This commit is contained in:
munimunigamer 2026-02-18 01:36:48 -06:00
parent 87ea7e5fbc
commit 18298c8d1e
5 changed files with 260 additions and 315 deletions

View file

@ -160,158 +160,94 @@
</script>
{#if filtered.length > 0}
<div class="border-border/50 mt-2 border-t pt-2">
<div class="mt-2 flex flex-col gap-1">
{#if editMode && onValueChange}
<!-- Edit mode: vertical list of inline editors -->
<div class="flex flex-col gap-1.5">
{#each filtered as def (def.id)}
{@const rawVal = getRawValue(def)}
<RuntimeVariableEditor
definition={def}
currentValue={rawVal}
onChange={(v) => onValueChange(def.id, v)}
/>
{/each}
</div>
{#each filtered as def (def.id)}
{@const rawVal = getRawValue(def)}
<RuntimeVariableEditor
definition={def}
currentValue={rawVal}
onChange={(v) => onValueChange(def.id, v)}
/>
{/each}
{:else}
<!-- Display mode: flowing chip layout -->
<div class="flex flex-wrap gap-1.5">
{#each filtered as def (def.id)}
{@const rawVal = getRawValue(def)}
{@const isSet = rawVal != null}
{@const Icon = getIconComponent(def.icon)}
<!-- Display mode: vertical stat rows -->
{#each filtered as def (def.id)}
{@const rawVal = getRawValue(def)}
{@const isSet = rawVal != null}
{@const Icon = getIconComponent(def.icon)}
{#if def.variableType === 'number' && hasMinMax(def)}
<!-- Number with range: chip with stat bar -->
<div
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1"
style="background-color: color-mix(in srgb, {def.color} {isSet
? '12%'
: '6%'}, transparent)"
>
{#if Icon}
<Icon
class="h-3.5 w-3.5 shrink-0 {isSet ? '' : 'opacity-40'}"
style="color: {def.color}"
/>
{/if}
<div
class="rounded-md px-2 py-1"
style="background-color: color-mix(in srgb, {def.color} {isSet
? '10%'
: '5%'}, transparent)"
>
<!-- Label row: icon/name on left, value on right -->
<div class="flex items-center gap-1.5">
{#if Icon}
<Icon
class="h-3.5 w-3.5 shrink-0 {isSet ? '' : 'opacity-40'}"
style="color: {def.color}"
title={def.displayName}
/>
{:else}
<span
class="text-[10px] font-medium whitespace-nowrap {isSet ? '' : 'opacity-40'}"
style="color: {def.color}"
>
{def.displayName}
</span>
{#if isSet && typeof rawVal === 'number'}
<div class="bg-muted/50 relative h-3.5 w-16 overflow-hidden rounded-sm">
<div
class="h-full rounded-sm"
style="width: {getProgressPercent(
def,
rawVal,
)}%; background-color: {def.color}; opacity: 0.6"
></div>
<span
class="absolute inset-0 flex items-center justify-center text-[9px] font-bold tabular-nums"
style="color: {def.color}"
>
{rawVal}/{def.maxValue}
</span>
</div>
{:else}
<span class="text-[10px] italic opacity-40">--</span>
{/if}
</div>
{:else if def.variableType === 'number'}
<!-- Number without range: chip with value -->
<div
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1"
style="background-color: color-mix(in srgb, {def.color} {isSet
? '12%'
: '6%'}, transparent)"
>
{#if Icon}
<Icon
class="h-3.5 w-3.5 shrink-0 {isSet ? '' : 'opacity-40'}"
style="color: {def.color}"
/>
{/if}
<span
class="text-[10px] font-medium whitespace-nowrap {isSet ? '' : 'opacity-40'}"
class="text-[11px] font-medium whitespace-nowrap {isSet ? '' : 'opacity-40'}"
style="color: {def.color}"
>
{def.displayName}
</span>
{/if}
{#if def.variableType === 'number'}
{#if isSet}
<span class="text-xs font-bold tabular-nums" style="color: {def.color}"
>{rawVal}</span
>
<span class="ml-auto text-xs font-bold tabular-nums" style="color: {def.color}">
{rawVal}{#if hasMinMax(def)}<span class="opacity-50">/{def.maxValue}</span>{/if}
</span>
{:else}
<span class="text-[10px] italic opacity-40">--</span>
<span class="ml-auto text-[10px] italic opacity-40">--</span>
{/if}
</div>
{:else if def.variableType === 'enum'}
<!-- Enum: colored badge chip -->
<div
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1"
style="background-color: color-mix(in srgb, {def.color} {isSet
? '12%'
: '6%'}, transparent)"
>
{#if Icon}
<Icon
class="h-3.5 w-3.5 shrink-0 {isSet ? '' : 'opacity-40'}"
style="color: {def.color}"
/>
{/if}
<span
class="text-[10px] font-medium whitespace-nowrap {isSet ? '' : 'opacity-40'}"
style="color: {def.color}"
>
{def.displayName}
</span>
{:else if def.variableType === 'enum'}
{#if isSet}
<span class="text-[10px] font-bold" style="color: {def.color}">
<span class="ml-auto text-[11px] font-semibold" style="color: {def.color}">
{getEnumLabel(def, rawVal)}
</span>
{:else}
<span class="text-[10px] italic opacity-40">--</span>
<span class="ml-auto text-[10px] italic opacity-40">--</span>
{/if}
</div>
{:else}
<!-- Text: colored text chip -->
<div
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1"
style="background-color: color-mix(in srgb, {def.color} {isSet
? '12%'
: '6%'}, transparent)"
>
{#if Icon}
<Icon
class="h-3.5 w-3.5 shrink-0 {isSet ? '' : 'opacity-40'}"
style="color: {def.color}"
/>
{:else}
<!-- Text: "not set" indicator on right when empty -->
{#if !isSet}
<span class="ml-auto text-[10px] italic opacity-40">--</span>
{/if}
<span
class="text-[10px] font-medium whitespace-nowrap {isSet ? '' : 'opacity-40'}"
style="color: {def.color}"
>
{def.displayName}
</span>
{#if isSet}
<span
class="max-w-[100px] truncate text-[10px] font-medium"
style="color: {def.color}"
title={String(rawVal)}
>
{rawVal}
</span>
{:else}
<span class="text-[10px] italic opacity-40">--</span>
{/if}
</div>
<!-- Full-width progress bar for number with range -->
{#if def.variableType === 'number' && hasMinMax(def)}
<div class="bg-muted/50 mt-1 h-1.5 w-full overflow-hidden rounded-full">
{#if isSet && typeof rawVal === 'number'}
<div
class="h-full rounded-full"
style="width: {getProgressPercent(
def,
rawVal,
)}%; background-color: {def.color}; opacity: 0.7"
></div>
{/if}
</div>
{/if}
{/each}
</div>
<!-- Full text value below label -->
{#if def.variableType === 'text' && isSet}
<p class="mt-0.5 text-[11px] leading-snug" style="color: {def.color}; opacity: 0.8">
{rawVal}
</p>
{/if}
</div>
{/each}
{/if}
</div>
{/if}

View file

@ -103,12 +103,12 @@
Gauge,
}
let icon = $derived(definition.icon ? (ICON_MAP[definition.icon] ?? null) : null)
let Icon = $derived(definition.icon ? (ICON_MAP[definition.icon] ?? null) : null)
// Local state for text/number inputs
let textValue = $state(currentValue != null ? String(currentValue) : '')
let numValue = $state(currentValue != null ? Number(currentValue) : 0)
let enumValue = $state(currentValue != null ? String(currentValue) : '')
// Local state for text/number inputs (synced from props via $effect below)
let textValue = $state('')
let numValue = $state(0)
let enumValue = $state('')
// Sync external changes
$effect(() => {
@ -151,20 +151,15 @@
<div class="flex items-center gap-1.5">
<!-- Icon or label -->
<div class="flex w-5 shrink-0 items-center justify-center" title={definition.displayName}>
{#if icon}
<svelte:component this={icon} class="h-3.5 w-3.5" style="color: {definition.color}" />
{:else}
<span class="text-[10px] font-medium" style="color: {definition.color}">
{definition.displayName.slice(0, 2)}
</span>
{/if}
</div>
<!-- Label -->
<span class="text-muted-foreground max-w-[80px] min-w-0 shrink-0 truncate text-xs">
{definition.displayName}
</span>
{#if Icon}
<div class="flex w-5 shrink-0 items-center justify-center" title={definition.displayName}>
<Icon class="h-3.5 w-3.5" style="color: {definition.color}" />
</div>
{:else}
<span class="text-muted-foreground max-w-[80px] min-w-0 shrink-0 truncate text-xs">
{definition.displayName}
</span>
{/if}
<!-- Editor -->
<div class="min-w-0 flex-1">

View file

@ -61,7 +61,7 @@ export class ClassifierService {
/**
* Classify a narrative response to extract world state changes.
* When the story's pack defines runtime variables, the schema is dynamically
* extended to include customVars extraction in the same LLM pass.
* extended to include inline runtime variable extraction in the same LLM pass.
*/
async classify(
context: ClassificationContext,
@ -87,7 +87,7 @@ export class ClassifierService {
runtimeVarsByType = this.groupByEntityType(runtimeVars)
}
// Build the schema: extended with customVars if runtime variables exist, else base
// Build the schema: extended with inline vars if runtime variables exist, else base
const schema =
runtimeVars.length > 0
? buildExtendedClassificationSchema(runtimeVarsByType)
@ -268,7 +268,7 @@ export class ClassifierService {
})
sections.push(
`For ${labels.updates}/${labels.new}, also extract these custom variables in a \`customVars\` object:\n${varLines.join('\n')}`,
`For ${labels.updates}/${labels.new}, include these as direct fields alongside standard fields:\n${varLines.join('\n')}`,
)
}
@ -279,7 +279,7 @@ export class ClassifierService {
/**
* Post-process: clamp number-type runtime variable values to min/max constraints.
* Walks through all entity updates/new entities and clamps customVars number values.
* Walks through all entity updates/new entities and clamps inline number values.
*/
private clampRuntimeVarNumbers(
result: ExtendedClassificationResult,
@ -296,41 +296,40 @@ export class ClassifierService {
if (numberDefs.size === 0) return
// Clamp values in update changes.customVars
const clampCustomVars = (customVars: Record<string, unknown> | undefined) => {
if (!customVars) return
for (const [key, value] of Object.entries(customVars)) {
// Clamp inline number values on an object
const clampInlineVars = (obj: Record<string, unknown>) => {
for (const [key, value] of Object.entries(obj)) {
const def = numberDefs.get(key)
if (def && typeof value === 'number') {
customVars[key] = clampNumber(value, def.minValue, def.maxValue)
obj[key] = clampNumber(value, def.minValue, def.maxValue)
}
}
}
// Walk all entity types
// Walk all entity types — vars are inline on changes/entity objects
for (const update of result.entryUpdates.characterUpdates) {
clampCustomVars(update.changes?.customVars)
clampInlineVars(update.changes as unknown as Record<string, unknown>)
}
for (const entity of result.entryUpdates.newCharacters) {
clampCustomVars(entity.customVars)
clampInlineVars(entity as unknown as Record<string, unknown>)
}
for (const update of result.entryUpdates.locationUpdates) {
clampCustomVars(update.changes?.customVars)
clampInlineVars(update.changes as unknown as Record<string, unknown>)
}
for (const entity of result.entryUpdates.newLocations) {
clampCustomVars(entity.customVars)
clampInlineVars(entity as unknown as Record<string, unknown>)
}
for (const update of result.entryUpdates.itemUpdates) {
clampCustomVars(update.changes?.customVars)
clampInlineVars(update.changes as unknown as Record<string, unknown>)
}
for (const entity of result.entryUpdates.newItems) {
clampCustomVars(entity.customVars)
clampInlineVars(entity as unknown as Record<string, unknown>)
}
for (const update of result.entryUpdates.storyBeatUpdates) {
clampCustomVars(update.changes?.customVars)
clampInlineVars(update.changes as unknown as Record<string, unknown>)
}
for (const entity of result.entryUpdates.newStoryBeats) {
clampCustomVars(entity.customVars)
clampInlineVars(entity as unknown as Record<string, unknown>)
}
}

View file

@ -6,8 +6,9 @@
* custom variable extraction when a story's pack defines runtime variables.
*
* Key design decisions:
* - Runtime vars are added as INLINE fields (not nested under `customVars`)
* - Single-element enums use z.literal() directly (z.union crashes with <2 items)
* - Number min/max constraints are NOT enforced in the schema; we clamp after extraction
* - Number min/max are included in .describe() for LLM guidance; clamped post-extraction
* - Variables with defaultValue are marked .optional() in the schema
* - The LLM sees variableName as the key, but stored values are keyed by defId
*/
@ -28,84 +29,103 @@ import {
type ClassificationResult,
} from './classifier'
// ============================================================================
// Variable Description Builder
// ============================================================================
/**
* Build a description string for a runtime variable, including min/max range for numbers.
* This becomes the `// comment` in the schema the LLM sees.
*/
function buildVariableDescription(def: RuntimeVariable): string {
const desc = def.description || def.displayName
if (def.variableType === 'number') {
if (def.minValue !== undefined && def.maxValue !== undefined) {
return `${desc} (range: ${def.minValue}-${def.maxValue})`
} else if (def.minValue !== undefined) {
return `${desc} (min: ${def.minValue})`
} else if (def.maxValue !== undefined) {
return `${desc} (max: ${def.maxValue})`
}
}
return desc
}
// ============================================================================
// Single Variable Schema Builder
// ============================================================================
/**
* Build a Zod schema for a single runtime variable definition.
*
* - text: z.string()
* - number: z.number() (no min/max -- clamped post-extraction)
* - enum: z.literal() for 1 option, z.union() for 2+
*
* Variables with a defaultValue are marked .optional().
* Build the base (non-optional) Zod schema for a runtime variable.
* Used internally; call buildVariableSchema() for the version with optionality.
*/
export function buildVariableSchema(def: RuntimeVariable): z.ZodTypeAny {
const desc = def.description || def.displayName
const hasDefault = def.defaultValue !== undefined && def.defaultValue !== null
function buildVariableBaseSchema(def: RuntimeVariable): z.ZodTypeAny {
const desc = buildVariableDescription(def)
switch (def.variableType) {
case 'text': {
const base = z.string().describe(desc)
return hasDefault ? base.optional() : base
}
case 'text':
return z.string().describe(desc)
case 'number': {
const base = z.number().describe(desc)
return hasDefault ? base.optional() : base
}
case 'number':
return z.number().describe(desc)
case 'enum': {
const options = def.enumOptions ?? []
if (options.length === 0) {
// No options defined -- fall back to string
const base = z.string().describe(desc)
return hasDefault ? base.optional() : base
}
if (options.length === 0) return z.string().describe(desc)
if (options.length === 1) return z.literal(options[0].value).describe(desc)
if (options.length === 1) {
// Single-element: use z.literal directly (z.union requires >= 2)
const base = z.literal(options[0].value).describe(desc)
return hasDefault ? base.optional() : base
}
// 2+ options: z.union of literals
const literals = options.map((opt) => z.literal(opt.value)) as [
z.ZodLiteral<string>,
z.ZodLiteral<string>,
...z.ZodLiteral<string>[],
]
const base = z.union(literals).describe(desc)
return hasDefault ? base.optional() : base
return z.union(literals).describe(desc)
}
default:
// Fallback for unknown type
return z.string().describe(desc).optional()
return z.string().describe(desc)
}
}
/**
* Build a Zod schema for a single runtime variable definition.
* Variables with a defaultValue are marked .optional().
*/
export function buildVariableSchema(def: RuntimeVariable): z.ZodTypeAny {
const base = buildVariableBaseSchema(def)
const hasDefault = def.defaultValue !== undefined && def.defaultValue !== null
return hasDefault ? base.optional() : base
}
// ============================================================================
// Entity Custom Vars Schema Builder
// Entity Variable Shape Builder
// ============================================================================
/**
* Build a z.object schema for a set of runtime variables (already filtered to one entity type).
* Each field key is the variable's variableName, value is the built schema.
* Returns null if the variables array is empty.
* Build a Zod shape (Record of field schemas) for runtime variables of one entity type.
* Returns null if no variables.
*
* @param allOptional - true for update schemas (only include changed fields),
* false for new entity schemas (required fields stay required)
*/
export function buildEntityCustomVarsSchema(
export function buildEntityVarsShape(
variables: RuntimeVariable[],
): z.ZodObject<z.ZodRawShape> | null {
allOptional: boolean,
): z.ZodRawShape | null {
if (variables.length === 0) return null
const shape: z.ZodRawShape = {}
for (const def of variables) {
shape[def.variableName] = buildVariableSchema(def)
// For updates: all vars optional (only send what changed)
// For new entities: respect defaultValue-based optionality
shape[def.variableName] = allOptional
? buildVariableBaseSchema(def).optional()
: buildVariableSchema(def)
}
return z.object(shape).describe('Custom runtime variables for this entity')
return shape
}
// ============================================================================
@ -123,7 +143,7 @@ const ENTITY_TYPE_TO_SCHEMA_FIELDS: Record<RuntimeEntityType, { updates: string;
}
/**
* Base update/new schemas per entity type -- used to extend with customVars.
* Base update/new schemas per entity type -- used to extend with inline vars.
*/
const BASE_UPDATE_SCHEMAS: Record<RuntimeEntityType, z.ZodObject<z.ZodRawShape>> = {
character: characterUpdateSchema as unknown as z.ZodObject<z.ZodRawShape>,
@ -140,12 +160,12 @@ const BASE_NEW_SCHEMAS: Record<RuntimeEntityType, z.ZodObject<z.ZodRawShape>> =
}
/**
* Build an extended classification schema that includes customVars fields
* for entity types that have runtime variable definitions.
* Build an extended classification schema that includes runtime variable fields
* INLINE on entity update/new schemas (not nested under a `customVars` object).
*
* For each entity type with runtime variables:
* - Update schemas get customVars added inside their `changes` object
* - New entity schemas get customVars added at the top level
* - Update schemas get var fields added directly inside their `changes` object (all optional)
* - New entity schemas get var fields added at the top level (with default-based optionality)
*
* If no runtime variables exist for any entity type, returns the base schema unchanged.
*/
@ -168,30 +188,25 @@ export function buildExtendedClassificationSchema(
for (const entityType of ['character', 'location', 'item', 'story_beat'] as RuntimeEntityType[]) {
const fields = ENTITY_TYPE_TO_SCHEMA_FIELDS[entityType]
const vars = runtimeVarsByEntityType[entityType]
const customVarsSchema = vars ? buildEntityCustomVarsSchema(vars) : null
const updateVarsShape = vars ? buildEntityVarsShape(vars, true) : null
const newVarsShape = vars ? buildEntityVarsShape(vars, false) : null
if (customVarsSchema) {
// Extend the update schema: add customVars inside the `changes` object
if (updateVarsShape) {
// Extend the update schema: add var fields directly inside `changes`
const baseUpdate = BASE_UPDATE_SCHEMAS[entityType]
const changesKey = entityType === 'story_beat' ? 'changes' : 'changes'
const originalChanges = (baseUpdate.shape as Record<string, z.ZodTypeAny>)[changesKey]
const originalChanges = (baseUpdate.shape as Record<string, z.ZodTypeAny>).changes
if (originalChanges && originalChanges instanceof z.ZodObject) {
const extendedChanges = originalChanges.extend({
customVars: customVarsSchema.optional(),
})
const extendedUpdate = baseUpdate.extend({ [changesKey]: extendedChanges })
const extendedChanges = originalChanges.extend(updateVarsShape)
const extendedUpdate = baseUpdate.extend({ changes: extendedChanges })
entryUpdatesShape[fields.updates] = z.array(extendedUpdate).default([])
} else {
// Fallback: use base schema as-is
entryUpdatesShape[fields.updates] = z.array(baseUpdate).default([])
}
// Extend the new entity schema: add customVars at top level
// Extend the new entity schema: add var fields at top level
const baseNew = BASE_NEW_SCHEMAS[entityType]
const extendedNew = baseNew.extend({
customVars: customVarsSchema.optional(),
})
const extendedNew = baseNew.extend(newVarsShape!)
entryUpdatesShape[fields.new] = z.array(extendedNew).default([])
} else {
// No variables for this entity type: use base schemas
@ -210,50 +225,31 @@ export function buildExtendedClassificationSchema(
// Extended Classification Result Type
// ============================================================================
/** Classification result that may include customVars from runtime variable extraction. */
/**
* Classification result that may include inline runtime variable fields.
* Runtime variables appear as direct fields on changes/entity objects
* (e.g., `health: 80` alongside `status: "active"`), not nested under `customVars`.
*
* Use extractInlineCustomVars() to pick out the variable fields from these objects.
*/
export type ExtendedClassificationResult = ClassificationResult & {
entryUpdates: ClassificationResult['entryUpdates'] & {
characterUpdates: Array<
ClassificationResult['entryUpdates']['characterUpdates'][number] & {
changes: { customVars?: Record<string, unknown> }
}
>
newCharacters: Array<
ClassificationResult['entryUpdates']['newCharacters'][number] & {
customVars?: Record<string, unknown>
}
>
locationUpdates: Array<
ClassificationResult['entryUpdates']['locationUpdates'][number] & {
changes: { customVars?: Record<string, unknown> }
}
>
newLocations: Array<
ClassificationResult['entryUpdates']['newLocations'][number] & {
customVars?: Record<string, unknown>
}
>
itemUpdates: Array<
ClassificationResult['entryUpdates']['itemUpdates'][number] & {
changes: { customVars?: Record<string, unknown> }
}
>
newItems: Array<
ClassificationResult['entryUpdates']['newItems'][number] & {
customVars?: Record<string, unknown>
}
>
storyBeatUpdates: Array<
ClassificationResult['entryUpdates']['storyBeatUpdates'][number] & {
changes: { customVars?: Record<string, unknown> }
}
>
newStoryBeats: Array<
ClassificationResult['entryUpdates']['newStoryBeats'][number] & {
customVars?: Record<string, unknown>
}
>
}
/** Internal metadata: runtime variable definitions for use by applyClassificationResult. Not LLM output. */
_runtimeVarDefs?: RuntimeVariable[]
}
/**
* Extract inline runtime variable values from an LLM-generated object.
* Filters the object's entries against known variable names from defsByName.
*/
export function extractInlineCustomVars(
obj: Record<string, unknown>,
defsByName: Map<string, RuntimeVariable>,
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
if (defsByName.has(key) && value !== undefined) {
result[key] = value
}
}
return result
}

View file

@ -26,7 +26,10 @@ import { database } from '$lib/services/database'
import { rollbackService } from '$lib/services/rollbackService'
import { ui } from './ui.svelte'
import { settings } from './settings.svelte'
import type { ExtendedClassificationResult } from '$lib/services/ai/sdk/schemas/runtime-variables'
import {
extractInlineCustomVars,
type ExtendedClassificationResult,
} from '$lib/services/ai/sdk/schemas/runtime-variables'
import type { RuntimeVariable } from '$lib/services/packs/types'
import { DEFAULT_MEMORY_CONFIG } from '$lib/services/ai/generation/MemoryService'
import { convertToEntries, type ImportedEntry } from '$lib/services/lorebookImporter'
@ -52,28 +55,28 @@ function log(...args: any[]) {
}
/**
* Merge LLM-extracted customVars into entity metadata.runtimeVars.
* Merge LLM-extracted inline runtime vars into entity metadata.runtimeVars.
* Values are keyed by defId (RuntimeVariable.id), NOT variableName,
* so renames only change the definition -- stored values follow automatically.
*
* @param existingMetadata - Current entity metadata (may be null)
* @param customVars - LLM-extracted vars keyed by variableName
* @param inlineVars - LLM-extracted vars keyed by variableName (from extractInlineCustomVars)
* @param defsByName - Lookup from variableName to RuntimeVariable definition
* @returns Updated metadata with runtimeVars merged
*/
function mergeRuntimeVars(
existingMetadata: Record<string, unknown> | null,
customVars: Record<string, unknown> | undefined,
inlineVars: Record<string, unknown> | undefined,
defsByName: Map<string, RuntimeVariable>,
): Record<string, unknown> {
if (!customVars || Object.keys(customVars).length === 0) {
if (!inlineVars || Object.keys(inlineVars).length === 0) {
return existingMetadata ?? {}
}
const base = existingMetadata ?? {}
const runtimeVars = { ...((base.runtimeVars as Record<string, unknown>) ?? {}) }
for (const [key, value] of Object.entries(customVars)) {
for (const [key, value] of Object.entries(inlineVars)) {
const def = defsByName.get(key)
if (def) {
runtimeVars[def.id] = { variableName: def.variableName, v: value }
@ -1963,13 +1966,13 @@ class StoryStore {
) {
changes.visualDescriptors = update.changes.visualDescriptors
}
// Merge runtime variable values into metadata if present
if (update.changes.customVars && Object.keys(update.changes.customVars).length > 0) {
changes.metadata = mergeRuntimeVars(
existing.metadata,
update.changes.customVars,
defsByName,
)
// Merge inline runtime variable values into metadata if present
const charInlineVars = extractInlineCustomVars(
update.changes as unknown as Record<string, unknown>,
defsByName,
)
if (Object.keys(charInlineVars).length > 0) {
changes.metadata = mergeRuntimeVars(existing.metadata, charInlineVars, defsByName)
}
// COW: ensure entity is owned by current branch before updating
const { entity: ownedChar, wasCowed: charWasCowed } = await this.cowCharacter(existing)
@ -2005,13 +2008,13 @@ class StoryStore {
: addition
}
}
// Merge runtime variable values into metadata if present
if (update.changes.customVars && Object.keys(update.changes.customVars).length > 0) {
changes.metadata = mergeRuntimeVars(
existing.metadata,
update.changes.customVars,
defsByName,
)
// Merge inline runtime variable values into metadata if present
const locInlineVars = extractInlineCustomVars(
update.changes as unknown as Record<string, unknown>,
defsByName,
)
if (Object.keys(locInlineVars).length > 0) {
changes.metadata = mergeRuntimeVars(existing.metadata, locInlineVars, defsByName)
}
// COW: ensure entity is owned by current branch before updating
@ -2092,13 +2095,13 @@ class StoryStore {
if (update.changes.quantity !== undefined) changes.quantity = update.changes.quantity
if (update.changes.equipped !== undefined) changes.equipped = update.changes.equipped
if (update.changes.location) changes.location = update.changes.location
// Merge runtime variable values into metadata if present
if (update.changes.customVars && Object.keys(update.changes.customVars).length > 0) {
changes.metadata = mergeRuntimeVars(
existing.metadata,
update.changes.customVars,
defsByName,
)
// Merge inline runtime variable values into metadata if present
const itemInlineVars = extractInlineCustomVars(
update.changes as unknown as Record<string, unknown>,
defsByName,
)
if (Object.keys(itemInlineVars).length > 0) {
changes.metadata = mergeRuntimeVars(existing.metadata, itemInlineVars, defsByName)
}
// COW: ensure entity is owned by current branch before updating
const { entity: ownedItem, wasCowed: itemWasCowed } = await this.cowItem(existing)
@ -2130,13 +2133,13 @@ class StoryStore {
}
}
if (update.changes.description) changes.description = update.changes.description
// Merge runtime variable values into metadata if present
if (update.changes.customVars && Object.keys(update.changes.customVars).length > 0) {
changes.metadata = mergeRuntimeVars(
existing.metadata,
update.changes.customVars,
defsByName,
)
// Merge inline runtime variable values into metadata if present
const beatInlineVars = extractInlineCustomVars(
update.changes as unknown as Record<string, unknown>,
defsByName,
)
if (Object.keys(beatInlineVars).length > 0) {
changes.metadata = mergeRuntimeVars(existing.metadata, beatInlineVars, defsByName)
}
// COW: ensure entity is owned by current branch before updating
const { entity: ownedBeat, wasCowed: beatWasCowed } = await this.cowStoryBeat(existing)
@ -2162,8 +2165,12 @@ class StoryStore {
if (!exists) {
log('Adding new character:', newChar.name)
const charMetadata: Record<string, unknown> = { source: 'classifier' }
if (newChar.customVars && Object.keys(newChar.customVars).length > 0) {
Object.assign(charMetadata, mergeRuntimeVars(null, newChar.customVars, defsByName))
const newCharInlineVars = extractInlineCustomVars(
newChar as unknown as Record<string, unknown>,
defsByName,
)
if (Object.keys(newCharInlineVars).length > 0) {
Object.assign(charMetadata, mergeRuntimeVars(null, newCharInlineVars, defsByName))
}
const character: Character = {
id: crypto.randomUUID(),
@ -2213,8 +2220,12 @@ class StoryStore {
}
}
const locMetadata: Record<string, unknown> = { source: 'classifier' }
if (newLoc.customVars && Object.keys(newLoc.customVars).length > 0) {
Object.assign(locMetadata, mergeRuntimeVars(null, newLoc.customVars, defsByName))
const newLocInlineVars = extractInlineCustomVars(
newLoc as unknown as Record<string, unknown>,
defsByName,
)
if (Object.keys(newLocInlineVars).length > 0) {
Object.assign(locMetadata, mergeRuntimeVars(null, newLocInlineVars, defsByName))
}
const location: Location = {
id: crypto.randomUUID(),
@ -2287,8 +2298,12 @@ class StoryStore {
if (!exists) {
log('Adding new item:', newItem.name)
const itemMetadata: Record<string, unknown> = { source: 'classifier' }
if (newItem.customVars && Object.keys(newItem.customVars).length > 0) {
Object.assign(itemMetadata, mergeRuntimeVars(null, newItem.customVars, defsByName))
const newItemInlineVars = extractInlineCustomVars(
newItem as unknown as Record<string, unknown>,
defsByName,
)
if (Object.keys(newItemInlineVars).length > 0) {
Object.assign(itemMetadata, mergeRuntimeVars(null, newItemInlineVars, defsByName))
}
const item: Item = {
id: crypto.randomUUID(),
@ -2317,8 +2332,12 @@ class StoryStore {
if (!exists) {
log('Adding new story beat:', newBeat.title)
const beatMetadata: Record<string, unknown> = { source: 'classifier' }
if (newBeat.customVars && Object.keys(newBeat.customVars).length > 0) {
Object.assign(beatMetadata, mergeRuntimeVars(null, newBeat.customVars, defsByName))
const newBeatInlineVars = extractInlineCustomVars(
newBeat as unknown as Record<string, unknown>,
defsByName,
)
if (Object.keys(newBeatInlineVars).length > 0) {
Object.assign(beatMetadata, mergeRuntimeVars(null, newBeatInlineVars, defsByName))
}
const beat: StoryBeat = {
id: crypto.randomUUID(),