visual-game-agent.js 7.37 KB
import * as v from 'valibot'

function strictRecord(key, value) {
  return v.intersect([
    v.custom(input => Boolean(input) && typeof input === 'object' && !Array.isArray(input), 'Expected an object record'),
    v.record(key, value),
  ])
}

export const VISUAL_GAME_STATE_SCALAR_VALUE_TYPES = Object.freeze(['string', 'number', 'boolean'])

const STATE_SCALAR_SCHEMAS = Object.freeze({
  boolean: v.boolean(),
  number: v.number(),
  string: v.string(),
})
const StateScalar = v.union(VISUAL_GAME_STATE_SCALAR_VALUE_TYPES.map(valueType => (
  STATE_SCALAR_SCHEMAS[valueType]
)))
const StateScalarRecord = strictRecord(v.string(), StateScalar)
const StringArray = v.array(v.string())

function patchField(definition) {
  switch (definition.schemaKind) {
    case 'boolean':
      return v.optional(v.boolean())
    case 'nullable_string':
      return v.optional(v.nullable(v.string()))
    case 'state_scalar_record':
      return v.optional(StateScalarRecord)
    case 'string':
      return v.optional(v.string())
    case 'string_array':
      return v.optional(StringArray)
    default:
      throw new Error(`Unhandled visual-game patch schema kind: ${definition.schemaKind}`)
  }
}

function patchEntries(definitions) {
  return Object.fromEntries(Object.entries(definitions).map(([field, definition]) => [
    field,
    patchField(definition),
  ]))
}

function patchDefinition(schemaKind, registryScope, registryPathKind, registryValueTypes) {
  return Object.freeze({
    schemaKind,
    registryScope,
    registryPathKind,
    registryValueTypes: Object.freeze([...registryValueTypes]),
  })
}

/**
 * CandidateDelta object patch fields and their stateRegistry representation.
 * Both the Valibot boundary and compile-time registry audit consume this table.
 */
export const VISUAL_GAME_OBJECT_STATE_PATCH_FIELDS = Object.freeze({
  known: patchDefinition('boolean', 'object_state', 'field', ['boolean']),
  inspected: patchDefinition('boolean', 'object_state', 'field', ['boolean']),
  usable: patchDefinition('boolean', 'object_state', 'field', ['boolean']),
  damaged: patchDefinition('boolean', 'object_state', 'field', ['boolean']),
  heldBy: patchDefinition('nullable_string', 'object_state', 'field', ['string']),
  location: patchDefinition('string', 'object_state', 'field', ['string']),
  state: patchDefinition('string', 'object_state', 'field', ['string']),
  flags: patchDefinition(
    'state_scalar_record',
    'object_state',
    'record_entry',
    VISUAL_GAME_STATE_SCALAR_VALUE_TYPES,
  ),
})

/**
 * CandidateDelta NPC patch fields and their stateRegistry representation.
 */
export const VISUAL_GAME_NPC_STATE_PATCH_FIELDS = Object.freeze({
  locationId: patchDefinition('string', 'npc_state', 'field', ['string']),
  position: patchDefinition('string', 'npc_state', 'field', ['string']),
  mood: patchDefinition('string', 'npc_state', 'field', ['string']),
  knownFacts: patchDefinition('string_array', 'npc_state', 'array_item', ['string']),
  revealedSecrets: patchDefinition('string_array', 'npc_state', 'array_item', ['string']),
  relationshipFlags: patchDefinition(
    'state_scalar_record',
    'npc_relationship_flag',
    'record_entry',
    VISUAL_GAME_STATE_SCALAR_VALUE_TYPES,
  ),
})

const ObjectStatePatch = v.strictObject(patchEntries(VISUAL_GAME_OBJECT_STATE_PATCH_FIELDS))
const NpcStatePatch = v.strictObject(patchEntries(VISUAL_GAME_NPC_STATE_PATCH_FIELDS))

const InventoryChanges = v.strictObject({
  add: StringArray,
  remove: StringArray,
})

const DeltaSuggestionEntries = {
  statChanges: v.optional(strictRecord(v.string(), v.number())),
  flagsSet: v.optional(StateScalarRecord),
  evidenceAdded: v.optional(StringArray),
  objectStateChanges: v.optional(strictRecord(v.string(), ObjectStatePatch)),
  npcStateChanges: v.optional(strictRecord(v.string(), NpcStatePatch)),
  inventoryChanges: v.optional(InventoryChanges),
  locationChange: v.optional(v.string()),
  notes: v.optional(StringArray),
}

const CandidateDelta = v.strictObject(DeltaSuggestionEntries)

const JudgementSuggestion = v.strictObject({
  valid: v.boolean(),
  reason: v.optional(v.string()),
  ...DeltaSuggestionEntries,
})

const SceneResult = v.strictObject({
  narration: v.string(),
  visibleFindings: StringArray,
  discoveredObjectIds: StringArray,
})

const StorySignal = v.union([
  v.strictObject({
    should_track: v.literal(false),
    reason: v.string(),
  }),
  v.strictObject({
    should_track: v.literal(true),
    continuation_tracker_id: v.nullable(v.string()),
    topic_cluster: v.string(),
    display_name: v.string(),
    topic_summary: v.string(),
    topic_kind: v.picklist([
      'relationship',
      'external_entity',
      'ally_intervention',
      'ritual',
      'countermeasure',
      'identity',
      'world_lore',
      'other',
    ]),
    player_intended_action: v.string(),
    decisive_action: v.boolean(),
    distinct_progress: v.boolean(),
    progress_marker: v.string(),
    reason: v.string(),
  }),
])

const BranchResolution = v.union([
  v.strictObject({ matched: v.literal(false) }),
  v.strictObject({
    branch_id: v.string(),
    matched: v.literal(true),
    decision: v.literal('continue'),
    summary: v.string(),
  }),
  v.strictObject({
    branch_id: v.string(),
    matched: v.literal(true),
    decision: v.literal('resolve'),
    resolution_summary: v.string(),
  }),
])

export const VisualGameKpAgentOutput = v.strictObject({
  expression_state: v.nullable(v.string()),
  action: v.strictObject({
    intent: v.picklist([
      'investigate',
      'accept',
      'refuse',
      'approach',
      'retreat',
      'question',
      'confront',
      'deceive',
      'destroy',
      'escape',
      'move',
      'comfort',
      'surrender',
      'attack',
      'wait',
    ]),
    targets: StringArray,
    stance: v.picklist(['cautious', 'trusting', 'hostile', 'neutral', 'deceptive']),
    risk: v.picklist(['low', 'normal', 'high', 'fatal']),
    claims: StringArray,
  }),
  judgementSuggestion: JudgementSuggestion,
  clueJudgements: v.array(v.strictObject({
    clueId: v.string(),
    discovered: v.boolean(),
    basis: v.string(),
  })),
  sceneResult: SceneResult,
  responsePolicy: v.strictObject({
    npcReaction: v.picklist(['none', 'optional', 'required']),
    reason: v.string(),
  }),
  npcDirective: v.strictObject({
    npcId: v.string(),
    tactic: v.string(),
    mood: v.picklist(['neutral', 'gentle', 'gentle_but_alert', 'cold_gentle', 'sad', 'threatening']),
    mustReactTo: StringArray,
    mustHide: StringArray,
  }),
  story_signal: v.optional(StorySignal),
  branch_resolution: v.optional(BranchResolution),
})

const ProposedNpcAction = v.strictObject({
  type: v.string(),
  targetId: v.string(),
  intent: v.string(),
  description: v.string(),
  candidate_delta: v.optional(CandidateDelta),
})

export const VisualGameNpcActorOutput = v.strictObject({
  expression_state: v.nullable(v.string()),
  narration: v.string(),
  npcLine: v.string(),
  proposedNpcActions: v.array(ProposedNpcAction),
  tacticUsed: v.string(),
  tone: v.string(),
  playerPromptType: v.picklist(['none', 'question', 'action_invitation', 'warning']),
  playerOptions: v.strictObject({
    say: v.string(),
    act: v.string(),
  }),
})

export const VisualGameNpcActionKpOutput = v.strictObject({
  npcId: v.string(),
  approvedNpcActions: v.array(v.strictObject({
    type: v.string(),
    targetId: v.string(),
    result: v.string(),
  })),
  judgementSuggestion: JudgementSuggestion,
  sceneResult: SceneResult,
})