world-simulator / frontend /src /components /AgentPanel.tsx
DeltaZN
feat: disable model selector & autoplay tick by default
7fee07b
Raw
History Blame Contribute Delete
11.2 kB
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>
);
}