| import type { |
| BeastSnapshot, |
| EntitySnapshot, |
| HouseSnapshot, |
| ResourceNodeSnapshot, |
| } from "../types"; |
| import { TooltipLabel } from "./TooltipLabel"; |
|
|
| type AgentPanelProps = { |
| entity: EntitySnapshot | null; |
| beast?: BeastSnapshot | null; |
| resource?: ResourceNodeSnapshot | null; |
| house?: HouseSnapshot | null; |
| entities?: EntitySnapshot[]; |
| onSelectEntity?: (id: string) => void; |
| }; |
|
|
| export function AgentPanel({ |
| entity, |
| beast = null, |
| resource = null, |
| house = null, |
| entities = [], |
| onSelectEntity, |
| }: AgentPanelProps) { |
| if (beast) { |
| return <BeastPanel beast={beast} />; |
| } |
| if (resource) { |
| return <ResourcePanel resource={resource} />; |
| } |
| if (house) { |
| return <HousePanel house={house} entities={entities} onSelectEntity={onSelectEntity} />; |
| } |
|
|
| const selectedName = entity?.label ?? "No NPC"; |
| const selectedRole = entity |
| ? [entity.country_id, entity.special_status ?? entity.role].filter(Boolean).join(" / ") |
| : "world"; |
|
|
| return ( |
| <aside className="agentPanel" aria-label="Selected NPC"> |
| <div className="panelHeader"> |
| <TooltipLabel className="panelTitle" value={selectedName} /> |
| <TooltipLabel className="panelRole" value={selectedRole} /> |
| </div> |
| {entity ? <AgentDetails entity={entity} /> : <EmptyAgentReadout />} |
| </aside> |
| ); |
| } |
|
|
| function BeastPanel({ beast }: { beast: BeastSnapshot }) { |
| const suffix = beast.id.replace(/^beast[_-]?/, ""); |
| const beastName = suffix ? `Beast ${suffix}` : "Beast"; |
| const healthLabel = `${beast.health} / ${beast.max_health} HP`; |
| const positionLabel = `x ${beast.position.x.toFixed(1)} / z ${beast.position.z.toFixed(1)}`; |
|
|
| return ( |
| <aside className="agentPanel" aria-label="Selected beast"> |
| <div className="panelHeader"> |
| <TooltipLabel className="panelTitle" value={beastName} /> |
| <TooltipLabel className="panelRole" value="beast" /> |
| </div> |
| <div className="agentReadout"> |
| <TooltipLabel className="readoutCell healthCell" value={healthLabel} /> |
| <TooltipLabel className="readoutCell" value={beast.state} /> |
| <TooltipLabel |
| className="readoutCell" |
| value={beast.target_npc_id ? `hunting ${beast.target_npc_id}` : "no target"} |
| /> |
| <TooltipLabel className="readoutCell" value={positionLabel} /> |
| </div> |
| </aside> |
| ); |
| } |
|
|
| function ResourcePanel({ resource }: { resource: ResourceNodeSnapshot }) { |
| const title = resource.type |
| ? `${resource.type.charAt(0).toUpperCase()}${resource.type.slice(1)} node` |
| : "Resource node"; |
| const amountLabel = resource.max_amount |
| ? `${resource.amount} / ${resource.max_amount}` |
| : `${resource.amount} left`; |
| const positionLabel = `x ${resource.position.x.toFixed(1)} / z ${resource.position.z.toFixed(1)}`; |
|
|
| return ( |
| <aside className="agentPanel" aria-label="Selected resource"> |
| <div className="panelHeader"> |
| <TooltipLabel className="panelTitle" value={title} /> |
| <TooltipLabel className="panelRole" value="resource" /> |
| </div> |
| <div className="agentReadout"> |
| <TooltipLabel className="readoutCell" value={amountLabel} /> |
| <TooltipLabel className="readoutCell" value={resource.id} /> |
| <TooltipLabel className="readoutCell" value={positionLabel} /> |
| </div> |
| </aside> |
| ); |
| } |
|
|
| type HousePanelProps = { |
| house: HouseSnapshot; |
| entities: EntitySnapshot[]; |
| onSelectEntity?: (id: string) => void; |
| }; |
|
|
| function HousePanel({ house, entities, onSelectEntity }: HousePanelProps) { |
| const isComplete = house.state === "completed"; |
| const suffix = house.id.replace(/^house[_-]?/, ""); |
| const title = suffix ? `Home ${suffix}` : "Home"; |
| const buildRatio = Math.min(1, house.build_progress / 10); |
| const statusLabel = isComplete |
| ? house.hp < house.max_hp |
| ? "damaged" |
| : "intact" |
| : `building ${Math.round(buildRatio * 100)}%`; |
| const positionLabel = `x ${house.position.x.toFixed(1)} / z ${house.position.z.toFixed(1)}`; |
|
|
| return ( |
| <aside className="agentPanel" aria-label="Selected house"> |
| <div className="panelHeader"> |
| <TooltipLabel className="panelTitle" value={title} /> |
| <TooltipLabel className="panelRole" value={isComplete ? "home" : "construction"} /> |
| </div> |
| <div className="agentReadout"> |
| <TooltipLabel className="readoutCell healthCell" value={`${house.hp} / ${house.max_hp} HP`} /> |
| <TooltipLabel className="readoutCell" value={statusLabel} /> |
| <TooltipLabel |
| className="readoutCell" |
| value={`inside ${house.occupant_ids.length}/${house.capacity}`} |
| /> |
| <TooltipLabel className="readoutCell" value={positionLabel} /> |
| </div> |
| <HouseResidentList |
| label="Inside now" |
| ids={house.occupant_ids} |
| emptyLabel="nobody inside" |
| entities={entities} |
| onSelectEntity={onSelectEntity} |
| /> |
| <HouseResidentList |
| label="Owners" |
| ids={house.owner_ids} |
| emptyLabel="unclaimed" |
| entities={entities} |
| onSelectEntity={onSelectEntity} |
| /> |
| </aside> |
| ); |
| } |
|
|
| type HouseResidentListProps = { |
| label: string; |
| ids: string[]; |
| emptyLabel: string; |
| entities: EntitySnapshot[]; |
| onSelectEntity?: (id: string) => void; |
| }; |
|
|
| function HouseResidentList({ label, ids, emptyLabel, entities, onSelectEntity }: HouseResidentListProps) { |
| return ( |
| <div className="houseResidents" aria-label={label}> |
| <span className="houseResidentsLabel">{label}</span> |
| <div className="relationshipStrip houseResidentStrip"> |
| {ids.length === 0 ? ( |
| <span className="relationshipPill">{emptyLabel}</span> |
| ) : ( |
| ids.map((id) => { |
| const npc = entities.find((entity) => entity.id === id); |
| return ( |
| <button |
| key={id} |
| type="button" |
| className="relationshipPill houseResidentPill" |
| onClick={() => onSelectEntity?.(id)} |
| title={`Select ${npc?.label ?? id}`} |
| > |
| {npc?.label ?? id} |
| </button> |
| ); |
| }) |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| type AgentDetailsProps = { |
| entity: EntitySnapshot; |
| }; |
|
|
| function AgentDetails({ entity }: AgentDetailsProps) { |
| return ( |
| <> |
| <AgentReadout entity={entity} /> |
| <SurvivalReadout entity={entity} /> |
| <ThinkingBlock entity={entity} /> |
| <RelationshipList entity={entity} /> |
| <MemoryList entity={entity} /> |
| </> |
| ); |
| } |
|
|
| function SurvivalReadout({ entity }: { entity: EntitySnapshot }) { |
| const { hunger, fear, safety, age, max_age, importance, goal, inventory } = entity.state; |
| if ( |
| hunger === undefined && |
| fear === undefined && |
| safety === undefined && |
| age === undefined && |
| importance === undefined && |
| goal === undefined && |
| !inventory |
| ) { |
| return null; |
| } |
|
|
| const inventoryLabel = inventory |
| ? `food ${inventory.food} / herbs ${inventory.herbs} / wood ${inventory.wood} / coins ${inventory.coins} / wpn ${inventory.weapon}` |
| : "-"; |
|
|
| return ( |
| <div className="agentReadout"> |
| {hunger !== undefined ? ( |
| <TooltipLabel className="readoutCell" value={`Hunger ${hunger.toFixed(0)}`} /> |
| ) : null} |
| {fear !== undefined ? ( |
| <TooltipLabel className="readoutCell" value={`Fear ${fear.toFixed(0)}`} /> |
| ) : null} |
| {safety !== undefined ? ( |
| <TooltipLabel className="readoutCell" value={`Safety ${safety.toFixed(0)}`} /> |
| ) : null} |
| {age !== undefined && max_age !== undefined ? ( |
| <TooltipLabel className="readoutCell" value={`Age ${age}/${max_age}`} /> |
| ) : null} |
| {importance !== undefined ? ( |
| <TooltipLabel className="readoutCell" value={`Importance ${importance.toFixed(1)}`} /> |
| ) : null} |
| {goal ? <TooltipLabel className="readoutCell" value={goal} /> : null} |
| <TooltipLabel className="readoutCell" value={inventoryLabel} /> |
| </div> |
| ); |
| } |
|
|
| function AgentReadout({ entity }: { entity: EntitySnapshot }) { |
| const selectedHealth = `${entity.state.health} / ${entity.state.max_health} HP`; |
| const selectedAttackDamage = `DMG ${entity.state.attack_damage}`; |
| const selectedPosition = `x ${entity.position.x.toFixed(1)} / z ${entity.position.z.toFixed(1)}`; |
|
|
| return ( |
| <div className="agentReadout"> |
| <TooltipLabel className="readoutCell healthCell" value={selectedHealth} /> |
| <TooltipLabel className="readoutCell" value={selectedAttackDamage} /> |
| <TooltipLabel className="readoutCell" value={entity.state.intention} /> |
| <TooltipLabel className="readoutCell" value={selectedPosition} /> |
| </div> |
| ); |
| } |
|
|
| function ThinkingBlock({ entity }: { entity: EntitySnapshot }) { |
| const reasoning = entity.state.last_reasoning; |
| if (!reasoning || !reasoning.trim()) { |
| return null; |
| } |
| const tick = entity.state.last_reasoning_tick; |
| return ( |
| <div className="thinkingBlock" aria-label={`${entity.label} thinking`}> |
| <TooltipLabel |
| className="thinkingLabel" |
| value={tick !== undefined && tick !== null ? `Thinking · tick ${tick}` : "Thinking"} |
| /> |
| <p className="thinkingText">{reasoning}</p> |
| </div> |
| ); |
| } |
|
|
| function RelationshipList({ entity }: { entity: EntitySnapshot }) { |
| const relationships = Object.entries(entity.state.relationships ?? {}) |
| .sort((left, right) => Math.abs(right[1]) - Math.abs(left[1]) || left[0].localeCompare(right[0])) |
| .slice(0, 3); |
|
|
| if (relationships.length === 0) { |
| return null; |
| } |
|
|
| return ( |
| <div className="relationshipStrip" aria-label={`${entity.label} relationships`}> |
| {relationships.map(([id, trust]) => ( |
| <TooltipLabel |
| key={id} |
| className="relationshipPill" |
| value={`${id} ${trust >= 0 ? "+" : ""}${trust.toFixed(2)}`} |
| /> |
| ))} |
| </div> |
| ); |
| } |
|
|
| function MemoryList({ entity }: { entity: EntitySnapshot }) { |
| const recentEpisodes = entity.state.recent_episodes ?? []; |
|
|
| return ( |
| <div className="memoryList" aria-label={`${entity.label} memories`}> |
| {entity.state.memory_summary ? ( |
| <div className="memorySummary"> |
| <TooltipLabel className="memoryTick" value="Summary" /> |
| <p>{entity.state.memory_summary}</p> |
| </div> |
| ) : null} |
| {recentEpisodes |
| .slice() |
| .reverse() |
| .map((episode) => ( |
| <div className="memoryItem" key={`${episode.tick}-${episode.kind}-${episode.summary}`}> |
| <TooltipLabel |
| className="memoryTick" |
| value={`Tick ${episode.tick} ${episode.kind}/${episode.perspective}`} |
| /> |
| <p>{episode.summary}</p> |
| </div> |
| ))} |
| {entity.state.memories |
| .slice() |
| .reverse() |
| .map((memory) => ( |
| <div className="memoryItem" key={`${memory.tick}-${memory.text}`}> |
| <TooltipLabel className="memoryTick" value={`Tick ${memory.tick}`} /> |
| <p>{memory.text}</p> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
|
|
| function EmptyAgentReadout() { |
| return ( |
| <div className="agentReadout"> |
| <TooltipLabel className="readoutCell" value="waiting" /> |
| </div> |
| ); |
| } |
|
|