diff --git a/docs/unreal-bridge-example.md b/docs/unreal-bridge-example.md new file mode 100644 index 0000000..0fc0265 --- /dev/null +++ b/docs/unreal-bridge-example.md @@ -0,0 +1,250 @@ +# Unreal Bridge Example + +This file describes a minimal Unreal-side bridge for `clawgo`. + +Goal: + +- render the same world as web +- keep `clawgo` authoritative +- let Unreal submit player actions and god-view edits + +## Recommended Modules + +Create three Unreal-side layers: + +1. `UClawgoWorldClient` +- owns HTTP and WebSocket connections +- fetches `/api/world` +- subscribes to `/api/runtime` +- sends `world_player_action` +- sends `world_entity_update` + +2. `AClawgoWorldManager` +- stores the latest normalized world snapshot +- maps locations / NPCs / entities / rooms to spawned actors +- applies interpolation + +3. `UClawgoAssetResolver` +- maps logical model keys to Unreal assets +- example: + - `npc.main` -> `/Game/World/Characters/BP_Main` + - `entity.table` -> `/Game/World/Furniture/BP_Table` + - `room.task` -> `/Game/World/Rooms/BP_TaskRoom` + +## Recommended UE Data Structures + +Use simple `USTRUCT`s mirroring the web/client contract. + +```cpp +USTRUCT(BlueprintType) +struct FClawgoPlacement +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly) FString Model; + UPROPERTY(BlueprintReadOnly) FVector Scale = FVector(1.0, 1.0, 1.0); + UPROPERTY(BlueprintReadOnly) FVector RotationEuler = FVector::ZeroVector; + UPROPERTY(BlueprintReadOnly) FVector Offset = FVector::ZeroVector; +}; + +USTRUCT(BlueprintType) +struct FClawgoLocation +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly) FString Id; + UPROPERTY(BlueprintReadOnly) FString Name; + UPROPERTY(BlueprintReadOnly) FString Description; + UPROPERTY(BlueprintReadOnly) TArray Neighbors; +}; + +USTRUCT(BlueprintType) +struct FClawgoEntity +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly) FString Id; + UPROPERTY(BlueprintReadOnly) FString Name; + UPROPERTY(BlueprintReadOnly) FString Type; + UPROPERTY(BlueprintReadOnly) FString LocationId; + UPROPERTY(BlueprintReadOnly) FClawgoPlacement Placement; + UPROPERTY(BlueprintReadOnly) TMap StateText; +}; + +USTRUCT(BlueprintType) +struct FClawgoNPCState +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly) FString NpcId; + UPROPERTY(BlueprintReadOnly) FString CurrentLocation; + UPROPERTY(BlueprintReadOnly) FString CurrentRoomId; + UPROPERTY(BlueprintReadOnly) FString Mood; + UPROPERTY(BlueprintReadOnly) FString Status; +}; + +USTRUCT(BlueprintType) +struct FClawgoRoom +{ + GENERATED_BODY() + + UPROPERTY(BlueprintReadOnly) FString Id; + UPROPERTY(BlueprintReadOnly) FString Name; + UPROPERTY(BlueprintReadOnly) FString LocationId; + UPROPERTY(BlueprintReadOnly) FString Status; + UPROPERTY(BlueprintReadOnly) FString TaskSummary; + UPROPERTY(BlueprintReadOnly) TArray AssignedNpcIds; +}; +``` + +## World Snapshot Normalization + +On Unreal side, normalize the payload exactly once. + +Recommended normalized snapshot: + +```cpp +struct FClawgoWorldSnapshot +{ + FString WorldId; + int64 Tick = 0; + TMap Locations; + TMap Entities; + TMap NPCStates; + TMap> Occupancy; + TMap> EntityOccupancy; + TMap> RoomOccupancy; + TMap Rooms; +}; +``` + +## Spawn Rules + +Recommended actor ownership: + +- location -> `AClawgoLocationAnchor` +- entity -> `AClawgoEntityActor` +- npc -> `AClawgoNPCActor` +- room -> `AClawgoRoomActor` + +Spawn keys: + +- location actor key: `location:` +- entity actor key: `entity:` +- npc actor key: `npc:` +- room actor key: `room:` + +## Placement Rules + +Final transform should be resolved from: + +- location anchor transform +- plus `entity.placement.offset` +- plus yaw from `entity.placement.rotation` +- plus scale from `entity.placement.scale` + +Recommended interpretation: + +- `rotation[1]` is yaw in radians +- convert radians to Unreal degrees before applying + +## Sync Loop + +Recommended runtime behavior: + +1. On boot: +- fetch `/api/world` +- build initial actors + +2. On websocket snapshot: +- if `tick` unchanged, ignore +- if `tick` advanced: + - update normalized snapshot + - diff actors + - move actors by interpolation + +3. If websocket disconnects: +- keep a low-frequency HTTP refresh fallback + +## Action Writeback + +### Player action example + +```json +{ + "action": "world_player_action", + "player_action": "move", + "location_id": "square" +} +``` + +### God-view entity move example + +```json +{ + "action": "world_entity_update", + "id": "bench", + "location_id": "commons", + "model": "entity.table", + "rotation_y": 1.57, + "scale": [1.2, 1.2, 1.2], + "offset": [0.8, 0, -0.4] +} +``` + +## NPC Animation Mapping + +Recommended mapping: + +- `status == "working"` -> `Interact` +- recent `npc_speak` event -> `Talk` +- location changed since last tick -> `Walk` +- otherwise -> `Idle` + +## Room Rendering + +Rooms should not replace the open world. + +Recommended behavior: + +- world view shows room portals / room pods +- selecting a room opens its isolated sub-scene +- NPCs assigned to the room appear inside while still remaining world-owned + +## Unreal Asset Resolver + +Keep a simple table or `UDataAsset`. + +Example: + +```cpp +TMap> ActorBlueprints; +TMap> StaticMeshes; +TMap> SkeletalMeshes; +``` + +Logical keys should stay aligned with web: + +- `npc.main` +- `npc.base` +- `entity.table` +- `entity.chair` +- `entity.crate` +- `room.task` +- `location.plaza` + +## Deployment Advice + +Do not make Unreal the world server. + +Keep: + +- `clawgo` as authority +- Unreal as client +- web as client + +This avoids: + +- divergent NPC logic +- duplicated quest logic +- different room assignment behavior across clients diff --git a/docs/unreal-world-sync.md b/docs/unreal-world-sync.md new file mode 100644 index 0000000..5979c31 --- /dev/null +++ b/docs/unreal-world-sync.md @@ -0,0 +1,143 @@ +# Unreal World Sync + +This document defines the recommended integration contract between `clawgo` and an Unreal Engine client. + +See also: + +- [Unreal Bridge Example](/Users/lpf/Desktop/project/clawgo/docs/unreal-bridge-example.md) + +## Authority + +- `clawgo` remains the authoritative world server. +- Unreal is a renderer and input client. +- Web and Unreal should consume the same world APIs and action APIs. + +## Read Path + +Unreal should subscribe to: + +- `GET /api/world` +- runtime websocket snapshots from `/api/runtime` + +Key world payloads to consume: + +- `world_id` +- `tick` +- `locations` +- `entities` +- `rooms` +- `occupancy` +- `entity_occupancy` +- `room_occupancy` +- `npc_states` +- `recent_events` + +## Write Path + +Player-originated actions should map to: + +- `world_player_action` + - `move` + - `speak` + - `interact` + - `quest_accept` + - `quest_progress` + - `quest_complete` + +World admin / god-view edits should map to: + +- `world_entity_update` +- `world_entity_create` +- `world_npc_create` +- `world_quest_create` + +## Placement Contract + +Entities expose a stable placement payload: + +```json +{ + "id": "bench", + "location_id": "commons", + "placement": { + "model": "entity.table", + "scale": [1.2, 1.2, 1.2], + "rotation": [0, 1.57, 0], + "offset": [0.8, 0, -0.4] + } +} +``` + +Interpretation: + +- `model`: shared logical asset key +- `scale`: local model scale +- `rotation`: radians, XYZ order +- `offset`: local offset inside the owning location + +Unreal should treat `location_id + offset` as the effective placement anchor. + +## Asset Pipeline + +Recommended source workflow: + +1. Author in Blender / Maya / DCC source files. +2. Export web assets as `GLB`. +3. Export Unreal assets as `FBX` or Unreal-preferred import format. +4. Keep the logical asset key the same across clients. + +Example: + +- logical key: `entity.table` +- web asset: `/models/furniture/table.glb` +- unreal asset: `/Game/World/Furniture/SM_Table` + +## Animation Contract + +Recommended shared action set for humanoid characters: + +- `Idle` +- `Walk` +- `Talk` +- `Interact` + +Unreal should choose animations from world state intent/status: + +- idle state -> `Idle` +- moving between anchors -> `Walk` +- speaking event -> `Talk` +- room/task work -> `Interact` + +## Rooms + +Rooms are task execution spaces created by the world mind. + +Unreal should render them as: + +- isolated interior pods +- task sub-scenes +- linked challenge spaces + +Use: + +- `rooms` +- `room_occupancy` + +to render membership and lifecycle. + +## Replication Model + +Do not replicate game authority into Unreal. + +Recommended flow: + +1. Unreal receives latest snapshot. +2. Unreal interpolates transforms locally. +3. Unreal submits user actions back to `clawgo`. +4. `clawgo` arbitrates and publishes the new truth. + +This keeps: + +- world logic in one place +- web and Unreal behavior aligned +- NPC autonomy consistent across clients diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 661c619..95b9274 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -443,6 +443,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers loop.model = strings.TrimSpace(primaryModel) } go loop.runAgentDigestTicker() + go loop.runWorldTicker() // Initialize provider fallback chain (primary + inferred providers). loop.providerChain = []providerCandidate{} loop.providerPool = map[string]providers.LLMProvider{} @@ -2840,6 +2841,19 @@ func (al *AgentLoop) runAgentDigestTicker() { } } +func (al *AgentLoop) runWorldTicker() { + if al == nil || al.worldRuntime == nil || !al.worldRuntime.Enabled() { + return + } + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + for range ticker.C { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _, _ = al.worldRuntime.AutoTick(ctx, "world_loop") + cancel() + } +} + func (al *AgentLoop) flushDueAgentDigests(now time.Time) { if al == nil || al.bus == nil { return diff --git a/pkg/agent/runtime_admin.go b/pkg/agent/runtime_admin.go index e53cc52..7715561 100644 --- a/pkg/agent/runtime_admin.go +++ b/pkg/agent/runtime_admin.go @@ -126,6 +126,38 @@ func (al *AgentLoop) runtimeAdminHandlers() map[string]runtimeAdminHandler { } return item, nil }, + "world_room_list": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + items, err := al.worldRuntime.RoomList() + if err != nil { + return nil, err + } + return map[string]interface{}{"items": items}, nil + }, + "world_room_get": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + item, found, err := al.worldRuntime.RoomGet(runtimeStringArg(args, "id")) + if err != nil { + return nil, err + } + return map[string]interface{}{"found": found, "item": item}, nil + }, + "world_location_update": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + return al.worldRuntime.UpdateLocation(ctx, args) + }, + "world_room_update": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + return al.worldRuntime.UpdateRoom(ctx, args) + }, "world_event_log": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { if al.worldRuntime == nil { return nil, fmt.Errorf("world runtime is not configured") @@ -148,6 +180,12 @@ func (al *AgentLoop) runtimeAdminHandlers() map[string]runtimeAdminHandler { } return al.worldRuntime.CreateEntity(ctx, args) }, + "world_entity_update": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { + if al.worldRuntime == nil { + return nil, fmt.Errorf("world runtime is not configured") + } + return al.worldRuntime.UpdateEntity(ctx, args) + }, "world_quest_list": func(ctx context.Context, args map[string]interface{}) (interface{}, error) { if al.worldRuntime == nil { return nil, fmt.Errorf("world runtime is not configured") diff --git a/pkg/agent/world_runtime.go b/pkg/agent/world_runtime.go index 1f897c7..91dcbeb 100644 --- a/pkg/agent/world_runtime.go +++ b/pkg/agent/world_runtime.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "sort" + "strconv" "strings" + "sync" "time" "github.com/YspCoder/clawgo/pkg/tools" @@ -20,6 +22,7 @@ type WorldRuntime struct { manager *tools.AgentManager maxCatchUp int maxNPCPerTick int + tickMu sync.Mutex } func NewWorldRuntime(workspace string, profiles *tools.AgentProfileStore, dispatcher *tools.AgentDispatcher, manager *tools.AgentManager) *WorldRuntime { @@ -38,10 +41,352 @@ func (wr *WorldRuntime) Enabled() bool { if wr == nil { return false } + _ = wr.ensureMainProfile() profiles, err := wr.worldProfiles() return err == nil && len(profiles) > 0 } +func (wr *WorldRuntime) AutoTick(ctx context.Context, source string) (string, error) { + return wr.Tick(ctx, source) +} + +func (wr *WorldRuntime) autonomyEnabled() bool { + return wr != nil && wr.manager != nil && wr.manager.HasProvider() +} + +func (wr *WorldRuntime) ensureMainProfile() error { + if wr == nil || wr.profiles == nil { + return nil + } + if _, ok, err := wr.profiles.Get("main"); err != nil { + return err + } else if ok { + return nil + } + _, err := wr.profiles.Upsert(tools.AgentProfile{ + AgentID: "main", + Name: "main", + Kind: "npc", + Role: "world-mind", + Persona: "The world mind that maintains continuity, delegates work, and protects coherence.", + HomeLocation: "commons", + DefaultGoals: []string{"maintain_world", "seed_story", "coordinate_npcs"}, + MemoryNamespace: "main", + Status: "active", + WorldTags: []string{"world_mind"}, + PerceptionScope: 2, + }) + return err +} + +func (wr *WorldRuntime) ensureAutonomousSeed(state *world.WorldState, npcStates map[string]world.NPCState) error { + if state == nil { + return nil + } + if err := wr.ensureMainProfile(); err != nil { + return err + } + if !wr.autonomyEnabled() { + return nil + } + for _, seed := range []struct { + id string + name string + persona string + location string + goals []string + }{ + {"caretaker", "Caretaker", "Keeps the commons stable, opens rooms, and keeps tasks moving.", "commons", []string{"maintain_commons", "support_main"}}, + {"merchant", "Merchant", "Trades, travels between square and commons, and creates local needs.", "square", []string{"trade", "seek_visitors"}}, + } { + if len(npcStates) >= 3 { + break + } + if _, exists := npcStates[seed.id]; exists { + continue + } + if _, err := wr.CreateNPC(context.Background(), map[string]interface{}{ + "npc_id": seed.id, + "name": seed.name, + "persona": seed.persona, + "home_location": seed.location, + "default_goals": seed.goals, + "role": "npc", + }); err != nil { + return err + } + npcStates[seed.id] = wr.engine.EnsureNPCState(world.NPCBlueprint{ + NPCID: seed.id, + DisplayName: seed.name, + Kind: "npc", + Role: "npc", + Persona: seed.persona, + HomeLocation: seed.location, + DefaultGoals: seed.goals, + }, npcStates[seed.id]) + } + if len(state.ActiveQuests) == 0 && state.Clock.Tick >= 2 { + questID := "stabilize-commons" + if _, exists := state.ActiveQuests[questID]; !exists { + state.ActiveQuests[questID] = world.QuestState{ + ID: questID, + Title: "Stabilize Commons", + Status: "open", + OwnerNPCID: "main", + Participants: []string{"main", "caretaker"}, + Summary: "Inspect commons and keep the world coherent.", + } + } + } + return nil +} + +func roomIDForQuest(questID string) string { + questID = normalizeWorldID(questID) + if questID == "" { + return "" + } + return "room-" + questID +} + +func normalizeNPCIDs(values []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, value := range values { + value = normalizeWorldID(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + sort.Strings(out) + return out +} + +func runtimeEntityPlacementArg(args map[string]interface{}) *world.EntityPlacement { + if args == nil { + return nil + } + stateArgs, _ := args["state"].(map[string]interface{}) + placement := &world.EntityPlacement{} + if model := strings.TrimSpace(fmt.Sprint(firstNonNil(args["model"], stateArgs["model"]))); model != "" && model != "" { + placement.Model = model + } + if scale, ok := runtimeTupleArg(args, "scale"); ok { + placement.Scale = scale + } else if scale, ok := runtimeTupleMap(stateArgs, "scale"); ok { + placement.Scale = scale + } + if rotation, ok := runtimeTupleArg(args, "rotation"); ok { + placement.Rotation = rotation + } else if rotation, ok := runtimeTupleMap(stateArgs, "rotation"); ok { + placement.Rotation = rotation + } else if rotationY := strings.TrimSpace(fmt.Sprint(firstNonNil(args["rotation_y"], stateArgs["rotation_y"]))); rotationY != "" && rotationY != "" { + placement.Rotation = [3]float64{0, runtimeNumberArg(firstNonNil(args["rotation_y"], stateArgs["rotation_y"])), 0} + } + if offset, ok := runtimeTupleArg(args, "offset"); ok { + placement.Offset = offset + } else if offset, ok := runtimeTupleMap(stateArgs, "offset"); ok { + placement.Offset = offset + } else if args["offset_x"] != nil || args["offset_y"] != nil || args["offset_z"] != nil { + placement.Offset = [3]float64{ + runtimeNumberArg(args["offset_x"]), + runtimeNumberArg(args["offset_y"]), + runtimeNumberArg(args["offset_z"]), + } + } else if stateArgs["offset_x"] != nil || stateArgs["offset_y"] != nil || stateArgs["offset_z"] != nil { + placement.Offset = [3]float64{ + runtimeNumberArg(stateArgs["offset_x"]), + runtimeNumberArg(stateArgs["offset_y"]), + runtimeNumberArg(stateArgs["offset_z"]), + } + } + if placement.Model == "" && placement.Scale == [3]float64{} && placement.Rotation == [3]float64{} && placement.Offset == [3]float64{} { + return nil + } + return placement +} + +func runtimeTupleArg(args map[string]interface{}, key string) ([3]float64, bool) { + var out [3]float64 + raw, ok := args[key] + if !ok { + return out, false + } + items, ok := raw.([]interface{}) + if !ok || len(items) < 3 { + return out, false + } + for i := 0; i < 3; i++ { + out[i] = runtimeNumberArg(items[i]) + } + return out, true +} + +func runtimeTupleMap(args map[string]interface{}, key string) ([3]float64, bool) { + var out [3]float64 + if args == nil { + return out, false + } + raw, ok := args[key] + if !ok { + return out, false + } + items, ok := raw.(map[string]interface{}) + if !ok { + array, ok := raw.([]interface{}) + if !ok || len(array) < 3 { + return out, false + } + for i := 0; i < 3; i++ { + out[i] = runtimeNumberArg(array[i]) + } + return out, true + } + if items["x"] == nil || items["y"] == nil || items["z"] == nil { + return out, false + } + out[0] = runtimeNumberArg(items["x"]) + out[1] = runtimeNumberArg(items["y"]) + out[2] = runtimeNumberArg(items["z"]) + return out, true +} + +func firstNonNil(values ...interface{}) interface{} { + for _, value := range values { + if value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { + return value + } + } + return nil +} + +func rawHasKey(args map[string]interface{}, key string) bool { + if args == nil { + return false + } + _, ok := args[key] + return ok +} + +func runtimeNumberArg(raw interface{}) float64 { + switch value := raw.(type) { + case int: + return float64(value) + case int64: + return float64(value) + case float64: + return value + case float32: + return float64(value) + case json.Number: + out, _ := value.Float64() + return out + default: + out, _ := strconv.ParseFloat(strings.TrimSpace(fmt.Sprint(raw)), 64) + return out + } +} + +func applyEntityPlacementStateToState(entity *world.Entity) { + if entity == nil || entity.Placement == nil { + return + } + if entity.State == nil { + entity.State = map[string]interface{}{} + } + if strings.TrimSpace(entity.Placement.Model) != "" { + entity.State["model"] = strings.TrimSpace(entity.Placement.Model) + } + if entity.Placement.Scale != [3]float64{} { + entity.State["scale"] = []float64{entity.Placement.Scale[0], entity.Placement.Scale[1], entity.Placement.Scale[2]} + } + if entity.Placement.Rotation != [3]float64{} { + entity.State["rotation"] = []float64{entity.Placement.Rotation[0], entity.Placement.Rotation[1], entity.Placement.Rotation[2]} + entity.State["rotation_y"] = entity.Placement.Rotation[1] + } + if entity.Placement.Offset != [3]float64{} { + entity.State["offset"] = []float64{entity.Placement.Offset[0], entity.Placement.Offset[1], entity.Placement.Offset[2]} + entity.State["offset_x"] = entity.Placement.Offset[0] + entity.State["offset_y"] = entity.Placement.Offset[1] + entity.State["offset_z"] = entity.Placement.Offset[2] + } +} + +func (wr *WorldRuntime) syncQuestRooms(state *world.WorldState, npcStates map[string]world.NPCState) { + if state == nil { + return + } + activeQuestRooms := map[string]struct{}{} + for _, quest := range state.ActiveQuests { + if strings.EqualFold(strings.TrimSpace(quest.Status), "completed") { + continue + } + members := normalizeNPCIDs(append(append([]string{}, quest.Participants...), quest.OwnerNPCID)) + if len(members) == 0 { + continue + } + roomID := roomIDForQuest(quest.ID) + if roomID == "" { + continue + } + activeQuestRooms[roomID] = struct{}{} + room := state.Rooms[roomID] + room.ID = roomID + room.Name = firstNonEmpty(room.Name, firstNonEmpty(quest.Title, quest.ID)) + room.Kind = firstNonEmpty(room.Kind, "task_room") + room.Status = "running" + room.LinkedQuestID = quest.ID + room.TaskSummary = firstNonEmpty(quest.Summary, room.TaskSummary, "task execution") + room.LocationID = firstNonEmpty(room.LocationID, "commons") + if owner := strings.TrimSpace(quest.OwnerNPCID); owner != "" { + if ownerState, ok := npcStates[owner]; ok && strings.TrimSpace(ownerState.CurrentLocation) != "" { + room.LocationID = ownerState.CurrentLocation + } + } + room.AssignedNPCIDs = members + room.ReleaseOnFinish = true + if room.CreatedTick == 0 { + room.CreatedTick = state.Clock.Tick + } + room.UpdatedTick = state.Clock.Tick + state.Rooms[roomID] = room + for _, npcID := range members { + npc := npcStates[npcID] + npc.CurrentRoomID = roomID + npc.Status = "working" + if strings.TrimSpace(room.LocationID) != "" { + npc.CurrentLocation = room.LocationID + } + npcStates[npcID] = npc + } + } + for roomID, room := range state.Rooms { + if _, ok := activeQuestRooms[roomID]; ok { + continue + } + if room.ReleaseOnFinish { + for _, npcID := range room.AssignedNPCIDs { + npc := npcStates[npcID] + if strings.TrimSpace(npc.CurrentRoomID) == roomID { + npc.CurrentRoomID = "" + if strings.TrimSpace(npc.Status) == "working" { + npc.Status = "active" + } + npcStates[npcID] = npc + } + } + room.Status = "closed" + room.UpdatedTick = state.Clock.Tick + } + state.Rooms[roomID] = room + } +} + func (wr *WorldRuntime) Snapshot(limit int) (interface{}, error) { state, npcStates, err := wr.ensureState() if err != nil { @@ -55,7 +400,7 @@ func (wr *WorldRuntime) Snapshot(limit int) (interface{}, error) { } func (wr *WorldRuntime) Tick(ctx context.Context, source string) (string, error) { - res, err := wr.advance(ctx, world.WorldTickRequest{Source: source}) + res, err := wr.advance(ctx, world.WorldTickRequest{Source: source, ForceStep: true}) if err != nil { return "", err } @@ -248,6 +593,51 @@ func (wr *WorldRuntime) WorldGet() (map[string]interface{}, error) { }, nil } +func (wr *WorldRuntime) RoomList() ([]map[string]interface{}, error) { + state, _, err := wr.ensureState() + if err != nil { + return nil, err + } + out := make([]map[string]interface{}, 0, len(state.Rooms)) + for _, room := range state.Rooms { + out = append(out, map[string]interface{}{ + "id": room.ID, + "name": room.Name, + "kind": room.Kind, + "status": room.Status, + "location_id": room.LocationID, + "task_summary": room.TaskSummary, + "linked_quest_id": room.LinkedQuestID, + "assigned_npc_ids": append([]string(nil), room.AssignedNPCIDs...), + "created_tick": room.CreatedTick, + "updated_tick": room.UpdatedTick, + }) + } + sort.Slice(out, func(i, j int) bool { return fmt.Sprint(out[i]["id"]) < fmt.Sprint(out[j]["id"]) }) + return out, nil +} + +func (wr *WorldRuntime) RoomGet(id string) (map[string]interface{}, bool, error) { + state, npcStates, err := wr.ensureState() + if err != nil { + return nil, false, err + } + room, ok := state.Rooms[strings.TrimSpace(id)] + if !ok { + return nil, false, nil + } + members := make([]world.NPCState, 0, len(room.AssignedNPCIDs)) + for _, npcID := range room.AssignedNPCIDs { + if npc, ok := npcStates[npcID]; ok { + members = append(members, npc) + } + } + return map[string]interface{}{ + "room": room, + "members": members, + }, true, nil +} + func (wr *WorldRuntime) EventLog(limit int) ([]map[string]interface{}, error) { events, err := wr.store.Events(limit) if err != nil { @@ -423,6 +813,15 @@ func (wr *WorldRuntime) CreateEntity(ctx context.Context, args map[string]interf if entity.State == nil { entity.State = map[string]interface{}{} } + if rawState, ok := args["state"].(map[string]interface{}); ok { + for key, value := range rawState { + entity.State[strings.TrimSpace(key)] = value + } + } + if placement := runtimeEntityPlacementArg(args); placement != nil { + entity.Placement = placement + applyEntityPlacementStateToState(&entity) + } state.Entities[entityID] = entity if err := wr.store.SaveWorldState(state); err != nil { return nil, err @@ -440,6 +839,148 @@ func (wr *WorldRuntime) CreateEntity(ctx context.Context, args map[string]interf return map[string]interface{}{"entity_id": entityID}, nil } +func (wr *WorldRuntime) UpdateEntity(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) { + _ = ctx + state, _, err := wr.ensureState() + if err != nil { + return nil, err + } + entityID := normalizeWorldID(firstNonEmpty(tools.MapStringArg(args, "entity_id"), tools.MapStringArg(args, "id"))) + if entityID == "" { + return nil, fmt.Errorf("entity_id is required") + } + entity, ok := state.Entities[entityID] + if !ok { + return nil, fmt.Errorf("entity not found: %s", entityID) + } + if locationID := normalizeWorldID(tools.MapStringArg(args, "location_id")); locationID != "" { + if _, exists := state.Locations[locationID]; !exists { + return nil, fmt.Errorf("unknown location_id: %s", locationID) + } + entity.LocationID = locationID + } + if name := strings.TrimSpace(tools.MapStringArg(args, "name")); name != "" { + entity.Name = name + } + if entityType := strings.TrimSpace(tools.MapStringArg(args, "entity_type")); entityType != "" { + entity.Type = entityType + } + if entity.State == nil { + entity.State = map[string]interface{}{} + } + if rawState, ok := args["state"].(map[string]interface{}); ok { + for key, value := range rawState { + entity.State[strings.TrimSpace(key)] = value + } + } + if placement := runtimeEntityPlacementArg(args); placement != nil { + entity.Placement = placement + applyEntityPlacementStateToState(&entity) + } + state.Entities[entityID] = entity + if err := wr.store.SaveWorldState(state); err != nil { + return nil, err + } + evt := world.WorldEvent{ + ID: fmt.Sprintf("evt-entity-update-%d", time.Now().UnixNano()), + Type: "entity_updated", + Source: "world", + LocationID: entity.LocationID, + Content: entityID, + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + _ = wr.store.AppendWorldEvent(evt) + return map[string]interface{}{"entity_id": entityID}, nil +} + +func (wr *WorldRuntime) UpdateLocation(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) { + _ = ctx + state, _, err := wr.ensureState() + if err != nil { + return nil, err + } + locationID := normalizeWorldID(firstNonEmpty(tools.MapStringArg(args, "location_id"), tools.MapStringArg(args, "id"))) + if locationID == "" { + return nil, fmt.Errorf("location_id is required") + } + location, ok := state.Locations[locationID] + if !ok { + return nil, fmt.Errorf("location not found: %s", locationID) + } + if name := strings.TrimSpace(tools.MapStringArg(args, "name")); name != "" { + location.Name = name + } + if description := strings.TrimSpace(tools.MapStringArg(args, "description")); description != "" { + location.Description = description + } + if model := strings.TrimSpace(tools.MapStringArg(args, "model")); model != "" || rawHasKey(args, "model") { + location.Model = model + } + state.Locations[locationID] = location + if err := wr.store.SaveWorldState(state); err != nil { + return nil, err + } + evt := world.WorldEvent{ + ID: fmt.Sprintf("evt-location-update-%d", time.Now().UnixNano()), + Type: "location_updated", + Source: "world", + LocationID: locationID, + Content: locationID, + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + _ = wr.store.AppendWorldEvent(evt) + return map[string]interface{}{"location_id": locationID}, nil +} + +func (wr *WorldRuntime) UpdateRoom(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) { + _ = ctx + state, _, err := wr.ensureState() + if err != nil { + return nil, err + } + roomID := normalizeWorldID(firstNonEmpty(tools.MapStringArg(args, "room_id"), tools.MapStringArg(args, "id"))) + if roomID == "" { + return nil, fmt.Errorf("room_id is required") + } + room, ok := state.Rooms[roomID] + if !ok { + return nil, fmt.Errorf("room not found: %s", roomID) + } + if name := strings.TrimSpace(tools.MapStringArg(args, "name")); name != "" { + room.Name = name + } + if kind := strings.TrimSpace(tools.MapStringArg(args, "kind")); kind != "" { + room.Kind = kind + } + if status := strings.TrimSpace(tools.MapStringArg(args, "status")); status != "" { + room.Status = status + } + if summary := strings.TrimSpace(tools.MapStringArg(args, "task_summary")); summary != "" { + room.TaskSummary = summary + } + if model := strings.TrimSpace(tools.MapStringArg(args, "model")); model != "" || rawHasKey(args, "model") { + room.Model = model + } + room.UpdatedTick = state.Clock.Tick + state.Rooms[roomID] = room + if err := wr.store.SaveWorldState(state); err != nil { + return nil, err + } + evt := world.WorldEvent{ + ID: fmt.Sprintf("evt-room-update-%d", time.Now().UnixNano()), + Type: "room_updated", + Source: "world", + LocationID: room.LocationID, + Content: roomID, + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + _ = wr.store.AppendWorldEvent(evt) + return map[string]interface{}{"room_id": roomID}, nil +} + func (wr *WorldRuntime) handleUserQuestInput(content string) (string, bool, error) { content = strings.TrimSpace(content) if content == "" { @@ -558,10 +1099,16 @@ func (wr *WorldRuntime) handlePlayerInput(ctx context.Context, content, location } func (wr *WorldRuntime) advance(ctx context.Context, req world.WorldTickRequest) (world.RenderedResult, error) { + wr.tickMu.Lock() + defer wr.tickMu.Unlock() state, npcStates, err := wr.ensureState() if err != nil { return world.RenderedResult{}, err } + if err := wr.ensureAutonomousSeed(&state, npcStates); err != nil { + return world.RenderedResult{}, err + } + wr.syncQuestRooms(&state, npcStates) catchUp := wr.computeCatchUp(state) if req.CatchUpTicks > 0 { catchUp = req.CatchUpTicks @@ -572,6 +1119,10 @@ func (wr *WorldRuntime) advance(ctx context.Context, req world.WorldTickRequest) var recentEvents []world.WorldEvent for i := 0; i < catchUp; i++ { wr.engine.NextTick(&state) + if err := wr.ensureAutonomousSeed(&state, npcStates); err != nil { + return world.RenderedResult{}, err + } + wr.syncQuestRooms(&state, npcStates) res, err := wr.runTick(ctx, &state, npcStates, nil, "background") if err != nil { return world.RenderedResult{}, err @@ -581,6 +1132,10 @@ func (wr *WorldRuntime) advance(ctx context.Context, req world.WorldTickRequest) var userEvent *world.WorldEvent if strings.TrimSpace(req.UserInput) != "" { wr.engine.NextTick(&state) + if err := wr.ensureAutonomousSeed(&state, npcStates); err != nil { + return world.RenderedResult{}, err + } + wr.syncQuestRooms(&state, npcStates) evt := wr.engine.BuildUserEvent(&state, req.UserInput, req.LocationID, req.ActorID) userEvent = &evt wr.engine.AppendRecentEvent(&state, evt, 20) @@ -588,6 +1143,15 @@ func (wr *WorldRuntime) advance(ctx context.Context, req world.WorldTickRequest) return world.RenderedResult{}, err } } + forceStepped := false + if strings.TrimSpace(req.UserInput) == "" && catchUp == 0 && req.ForceStep { + wr.engine.NextTick(&state) + if err := wr.ensureAutonomousSeed(&state, npcStates); err != nil { + return world.RenderedResult{}, err + } + wr.syncQuestRooms(&state, npcStates) + forceStepped = true + } var visible []world.WorldEvent if userEvent != nil { visible = append(visible, *userEvent) @@ -597,6 +1161,21 @@ func (wr *WorldRuntime) advance(ctx context.Context, req world.WorldTickRequest) return world.RenderedResult{}, err } recentEvents = append(recentEvents, res.RecentEvents...) + if forceStepped && len(recentEvents) == 0 { + id := fmt.Sprintf("evt-world-step-%d", time.Now().UnixNano()) + stepEvent := world.WorldEvent{ + ID: id, + Type: "world_tick", + Source: firstNonEmpty(req.Source, "world"), + ActorID: "main", + Content: "world advanced", + Tick: state.Clock.Tick, + CreatedAt: time.Now().UnixMilli(), + } + wr.engine.AppendRecentEvent(&state, stepEvent, 20) + _ = wr.store.AppendWorldEvent(stepEvent) + recentEvents = append(recentEvents, stepEvent) + } if err := wr.store.SaveNPCStates(npcStates); err != nil { return world.RenderedResult{}, err } @@ -687,6 +1266,7 @@ func (wr *WorldRuntime) decideNPCIntent(ctx context.Context, state world.WorldSt worldSnapshot := map[string]interface{}{ "tick": state.Clock.Tick, "locations": state.Locations, + "rooms": state.Rooms, "global_facts": state.GlobalFacts, } npcSnapshot := map[string]interface{}{ @@ -695,6 +1275,7 @@ func (wr *WorldRuntime) decideNPCIntent(ctx context.Context, state world.WorldSt "persona": profile.Persona, "traits": append([]string(nil), profile.Traits...), "current_location": npcState.CurrentLocation, + "current_room_id": npcState.CurrentRoomID, "goals_long_term": append([]string(nil), npcState.Goals.LongTerm...), "goals_short_term": append([]string(nil), npcState.Goals.ShortTerm...), } @@ -787,6 +1368,7 @@ func (wr *WorldRuntime) buildDecisionTask(profile tools.AgentProfile, npcState w "persona": profile.Persona, "traits": profile.Traits, "current_location": npcState.CurrentLocation, + "current_room_id": npcState.CurrentRoomID, "long_term_goals": npcState.Goals.LongTerm, "short_term_goals": npcState.Goals.ShortTerm, "visible_events": visible, @@ -798,6 +1380,9 @@ func (wr *WorldRuntime) buildDecisionTask(profile tools.AgentProfile, npcState w } func (wr *WorldRuntime) ensureState() (world.WorldState, map[string]world.NPCState, error) { + if err := wr.ensureMainProfile(); err != nil { + return world.WorldState{}, nil, err + } state, err := wr.store.LoadWorldState() if err != nil { return world.WorldState{}, nil, err @@ -811,19 +1396,14 @@ func (wr *WorldRuntime) ensureState() (world.WorldState, map[string]world.NPCSta if err != nil { return world.WorldState{}, nil, err } - changed := false for _, profile := range profiles { - current, exists := npcStates[profile.AgentID] + current := npcStates[profile.AgentID] next := wr.engine.EnsureNPCState(wr.profileBlueprint(profile), current) - if !exists || strings.TrimSpace(current.NPCID) == "" || strings.TrimSpace(current.CurrentLocation) == "" { - changed = true - } npcStates[profile.AgentID] = next } - if changed { - if err := wr.store.SaveNPCStates(npcStates); err != nil { - return world.WorldState{}, nil, err - } + wr.syncQuestRooms(&state, npcStates) + if err := wr.store.SaveNPCStates(npcStates); err != nil { + return world.WorldState{}, nil, err } if err := wr.store.SaveWorldState(state); err != nil { return world.WorldState{}, nil, err diff --git a/pkg/agent/world_runtime_test.go b/pkg/agent/world_runtime_test.go index 69790ce..3d99b93 100644 --- a/pkg/agent/world_runtime_test.go +++ b/pkg/agent/world_runtime_test.go @@ -172,6 +172,95 @@ func TestWorldRuntimeCreateEntityAndGet(t *testing.T) { } } +func TestWorldRuntimeUpdateEntityPlacement(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + runtime := NewWorldRuntime(workspace, manager.ProfileStore(), tools.NewAgentDispatcher(manager), manager) + if _, err := runtime.CreateEntity(context.Background(), map[string]interface{}{ + "entity_id": "bench", + "name": "Bench", + "entity_type": "table", + "location_id": "square", + }); err != nil { + t.Fatalf("create entity failed: %v", err) + } + if _, err := runtime.UpdateEntity(context.Background(), map[string]interface{}{ + "entity_id": "bench", + "location_id": "commons", + "model": "entity.table", + "rotation_y": 1.57, + "offset": []interface{}{0.5, 0, -0.25}, + "scale": []interface{}{1.2, 1.2, 1.2}, + }); err != nil { + t.Fatalf("update entity failed: %v", err) + } + entity, found, err := runtime.EntityGet("bench") + if err != nil || !found { + t.Fatalf("expected updated entity, found=%v err=%v", found, err) + } + data, _ := json.Marshal(entity) + text := string(data) + if !strings.Contains(text, `"location_id":"commons"`) { + t.Fatalf("expected updated location, got %s", text) + } + if !strings.Contains(text, `"model":"entity.table"`) { + t.Fatalf("expected updated model, got %s", text) + } +} + +func TestWorldRuntimeUpdateLocationAndRoomModel(t *testing.T) { + workspace := t.TempDir() + manager := tools.NewAgentManager(nil, workspace, nil) + runtime := NewWorldRuntime(workspace, manager.ProfileStore(), tools.NewAgentDispatcher(manager), manager) + + if _, err := runtime.UpdateLocation(context.Background(), map[string]interface{}{ + "location_id": "commons", + "model": "location.plaza", + "description": "Central commons", + }); err != nil { + t.Fatalf("update location failed: %v", err) + } + if _, err := runtime.CreateQuest(context.Background(), map[string]interface{}{ + "id": "room-test", + "title": "Room Test", + "owner_npc_id": "main", + "status": "accepted", + "summary": "test room", + }); err != nil { + t.Fatalf("create quest failed: %v", err) + } + state, npcStates, err := runtime.ensureState() + if err != nil { + t.Fatalf("ensure state failed: %v", err) + } + runtime.syncQuestRooms(&state, npcStates) + if err := runtime.store.SaveWorldState(state); err != nil { + t.Fatalf("save state failed: %v", err) + } + if _, err := runtime.UpdateRoom(context.Background(), map[string]interface{}{ + "room_id": "room-room-test", + "model": "room.task", + "name": "Task Room", + }); err != nil { + t.Fatalf("update room failed: %v", err) + } + + worldOut, err := runtime.WorldGet() + if err != nil { + t.Fatalf("world get failed: %v", err) + } + worldState, ok := worldOut["world_state"].(world.WorldState) + if !ok { + t.Fatalf("unexpected world_state payload: %T", worldOut["world_state"]) + } + if got := worldState.Locations["commons"].Model; got != "location.plaza" { + t.Fatalf("expected commons model updated, got %q", got) + } + if got := worldState.Rooms["room-room-test"].Model; got != "room.task" { + t.Fatalf("expected room model updated, got %q", got) + } +} + func TestWorldRuntimeSnapshotIncludesEntityOccupancyAfterInteract(t *testing.T) { workspace := t.TempDir() manager := tools.NewAgentManager(nil, workspace, nil) diff --git a/pkg/api/server_live.go b/pkg/api/server_live.go index 716f967..d02b335 100644 --- a/pkg/api/server_live.go +++ b/pkg/api/server_live.go @@ -59,7 +59,7 @@ func (s *Server) unsubscribeRuntimeLive(ch chan []byte) { } func (s *Server) runtimeLiveLoop() { - ticker := time.NewTicker(10 * time.Second) + ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { if !s.publishRuntimeSnapshot(context.Background()) { diff --git a/pkg/tools/agent.go b/pkg/tools/agent.go index 819578e..0c3560a 100644 --- a/pkg/tools/agent.go +++ b/pkg/tools/agent.go @@ -120,6 +120,10 @@ func NewAgentManager(provider providers.LLMProvider, workspace string, bus *bus. return mgr } +func (sm *AgentManager) HasProvider() bool { + return sm != nil && sm.provider != nil +} + func (sm *AgentManager) Spawn(ctx context.Context, opts AgentSpawnOptions) (string, error) { task, err := sm.spawnTask(ctx, opts) if err != nil { diff --git a/pkg/tools/world_tool.go b/pkg/tools/world_tool.go index 73570a9..c9131d7 100644 --- a/pkg/tools/world_tool.go +++ b/pkg/tools/world_tool.go @@ -14,6 +14,9 @@ type WorldToolRuntime interface { NPCGet(id string) (map[string]interface{}, bool, error) EntityList() ([]map[string]interface{}, error) EntityGet(id string) (map[string]interface{}, bool, error) + UpdateEntity(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) + UpdateLocation(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) + UpdateRoom(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) WorldGet() (map[string]interface{}, error) EventLog(limit int) ([]map[string]interface{}, error) CreateNPC(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) @@ -34,14 +37,14 @@ func NewWorldTool(runtime WorldToolRuntime) *WorldTool { func (t *WorldTool) Name() string { return "world" } func (t *WorldTool) Description() string { - return "Inspect and drive the world runtime: snapshot, tick, npc_list, npc_get, entity_list, entity_get, world_get, event_log, npc_create, entity_create, quest_list, quest_get, quest_create." + return "Inspect and drive the world runtime: snapshot, tick, npc_list, npc_get, entity_list, entity_get, entity_update, location_update, room_update, world_get, event_log, npc_create, entity_create, quest_list, quest_get, quest_create." } func (t *WorldTool) Parameters() map[string]interface{} { return map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "action": map[string]interface{}{"type": "string", "description": "snapshot|tick|npc_list|npc_get|entity_list|entity_get|world_get|event_log|npc_create|entity_create|quest_list|quest_get|quest_create"}, + "action": map[string]interface{}{"type": "string", "description": "snapshot|tick|npc_list|npc_get|entity_list|entity_get|entity_update|location_update|room_update|world_get|event_log|npc_create|entity_create|quest_list|quest_get|quest_create"}, "id": map[string]interface{}{"type": "string", "description": "npc id for npc_get"}, "limit": map[string]interface{}{"type": "integer", "description": "maximum event/snapshot items"}, "source": map[string]interface{}{"type": "string", "description": "tick source label"}, @@ -156,6 +159,24 @@ func (t *WorldTool) Execute(ctx context.Context, args map[string]interface{}) (s return "", err } return fmt.Sprintf("Created entity %s", strings.TrimSpace(MapStringArg(out, "entity_id"))), nil + case "entity_update": + out, err := t.runtime.UpdateEntity(ctx, args) + if err != nil { + return "", err + } + return fmt.Sprintf("Updated entity %s", strings.TrimSpace(MapStringArg(out, "entity_id"))), nil + case "location_update": + out, err := t.runtime.UpdateLocation(ctx, args) + if err != nil { + return "", err + } + return fmt.Sprintf("Updated location %s", strings.TrimSpace(MapStringArg(out, "location_id"))), nil + case "room_update": + out, err := t.runtime.UpdateRoom(ctx, args) + if err != nil { + return "", err + } + return fmt.Sprintf("Updated room %s", strings.TrimSpace(MapStringArg(out, "room_id"))), nil case "quest_list": out, err := t.runtime.QuestList() if err != nil { diff --git a/pkg/world/engine.go b/pkg/world/engine.go index 00c786c..c82d9f4 100644 --- a/pkg/world/engine.go +++ b/pkg/world/engine.go @@ -13,6 +13,96 @@ func NewEngine() *Engine { return &Engine{} } +func parsePlacementTuple(raw interface{}) ([3]float64, bool) { + var out [3]float64 + items, ok := raw.([]interface{}) + if !ok || len(items) < 3 { + return out, false + } + for i := 0; i < 3; i++ { + out[i] = numberToFloat(items[i]) + } + return out, true +} + +func parsePlacementTupleMap(raw interface{}, keys [3]string) ([3]float64, bool) { + var out [3]float64 + items, ok := raw.(map[string]interface{}) + if !ok { + return out, false + } + for i, key := range keys { + value, exists := items[key] + if !exists { + return out, false + } + out[i] = numberToFloat(value) + } + return out, true +} + +func parseEntityPlacement(raw map[string]interface{}) *EntityPlacement { + if len(raw) == 0 { + return nil + } + placement := &EntityPlacement{} + if model := strings.TrimSpace(fmt.Sprint(raw["model"])); model != "" { + placement.Model = model + } + if value, ok := parsePlacementTuple(raw["scale"]); ok { + placement.Scale = value + } else if value, ok := parsePlacementTupleMap(raw["scale"], [3]string{"x", "y", "z"}); ok { + placement.Scale = value + } + if value, ok := parsePlacementTuple(raw["rotation"]); ok { + placement.Rotation = value + } else if value, ok := parsePlacementTupleMap(raw["rotation"], [3]string{"x", "y", "z"}); ok { + placement.Rotation = value + } else if raw["rotation_y"] != nil { + placement.Rotation = [3]float64{0, numberToFloat(raw["rotation_y"]), 0} + } + if value, ok := parsePlacementTuple(raw["offset"]); ok { + placement.Offset = value + } else if value, ok := parsePlacementTupleMap(raw["offset"], [3]string{"x", "y", "z"}); ok { + placement.Offset = value + } else if raw["offset_x"] != nil || raw["offset_y"] != nil || raw["offset_z"] != nil { + placement.Offset = [3]float64{ + numberToFloat(raw["offset_x"]), + numberToFloat(raw["offset_y"]), + numberToFloat(raw["offset_z"]), + } + } + if placement.Model == "" && placement.Scale == [3]float64{} && placement.Rotation == [3]float64{} && placement.Offset == [3]float64{} { + return nil + } + return placement +} + +func applyEntityPlacementState(entity *Entity) { + if entity == nil || entity.Placement == nil { + return + } + if entity.State == nil { + entity.State = map[string]interface{}{} + } + if strings.TrimSpace(entity.Placement.Model) != "" { + entity.State["model"] = strings.TrimSpace(entity.Placement.Model) + } + if entity.Placement.Scale != [3]float64{} { + entity.State["scale"] = []float64{entity.Placement.Scale[0], entity.Placement.Scale[1], entity.Placement.Scale[2]} + } + if entity.Placement.Rotation != [3]float64{} { + entity.State["rotation"] = []float64{entity.Placement.Rotation[0], entity.Placement.Rotation[1], entity.Placement.Rotation[2]} + entity.State["rotation_y"] = entity.Placement.Rotation[1] + } + if entity.Placement.Offset != [3]float64{} { + entity.State["offset"] = []float64{entity.Placement.Offset[0], entity.Placement.Offset[1], entity.Placement.Offset[2]} + entity.State["offset_x"] = entity.Placement.Offset[0] + entity.State["offset_y"] = entity.Placement.Offset[1] + entity.State["offset_z"] = entity.Placement.Offset[2] + } +} + func (e *Engine) EnsureWorld(state *WorldState) { if state == nil { return @@ -39,6 +129,9 @@ func (e *Engine) EnsureWorld(state *WorldState) { if state.Entities == nil { state.Entities = map[string]Entity{} } + if state.Rooms == nil { + state.Rooms = map[string]RoomState{} + } if state.ActiveQuests == nil { state.ActiveQuests = map[string]QuestState{} } @@ -237,6 +330,7 @@ func (e *Engine) ApplyIntent(state *WorldState, npc *NPCState, intent ActionInte entity.State["last_effect"] = effect } e.applyProposedEffects(state, npc, intent, &entity) + applyEntityPlacementState(&entity) state.Entities[targetEntity] = entity delta.Applied = true npc.LastActiveTick = state.Clock.Tick @@ -297,6 +391,12 @@ func (e *Engine) applyProposedEffects(state *WorldState, npc *NPCState, intent A entity.State[strings.TrimSpace(key)] = value } } + if raw, ok := intent.ProposedEffects["entity_placement"].(map[string]interface{}); ok { + entity.Placement = parseEntityPlacement(raw) + } + if locationID := strings.TrimSpace(fmt.Sprint(intent.ProposedEffects["entity_location"])); locationID != "" { + entity.LocationID = locationID + } } if raw, ok := intent.ProposedEffects["quest_update"].(map[string]interface{}); ok { questID := strings.TrimSpace(fmt.Sprint(raw["id"])) @@ -346,16 +446,25 @@ func (e *Engine) Snapshot(state WorldState, npcStates map[string]NPCState, recen } active := make([]string, 0, len(npcStates)) quests := make([]QuestState, 0, len(state.ActiveQuests)) + rooms := make([]RoomState, 0, len(state.Rooms)) occupancy := map[string][]string{} entityOccupancy := map[string][]string{} + roomOccupancy := map[string][]string{} for id, npc := range npcStates { active = append(active, id) + if strings.TrimSpace(npc.CurrentRoomID) != "" { + roomOccupancy[npc.CurrentRoomID] = append(roomOccupancy[npc.CurrentRoomID], id) + continue + } loc := firstNonEmpty(npc.CurrentLocation, "commons") occupancy[loc] = append(occupancy[loc], id) } for _, quest := range state.ActiveQuests { quests = append(quests, quest) } + for _, room := range state.Rooms { + rooms = append(rooms, room) + } for id, entity := range state.Entities { loc := firstNonEmpty(entity.LocationID, "commons") entityOccupancy[loc] = append(entityOccupancy[loc], id) @@ -364,25 +473,34 @@ func (e *Engine) Snapshot(state WorldState, npcStates map[string]NPCState, recen sort.Slice(quests, func(i, j int) bool { return firstNonEmpty(quests[i].ID, quests[i].Title) < firstNonEmpty(quests[j].ID, quests[j].Title) }) + sort.Slice(rooms, func(i, j int) bool { + return firstNonEmpty(rooms[i].ID, rooms[i].Name) < firstNonEmpty(rooms[j].ID, rooms[j].Name) + }) for key := range occupancy { sort.Strings(occupancy[key]) } for key := range entityOccupancy { sort.Strings(entityOccupancy[key]) } + for key := range roomOccupancy { + sort.Strings(roomOccupancy[key]) + } return SnapshotSummary{ WorldID: state.WorldID, Tick: state.Clock.Tick, SimTimeUnix: state.Clock.SimTimeUnix, Player: state.Player, Locations: state.Locations, + Entities: state.Entities, NPCCount: len(npcStates), ActiveNPCs: active, Quests: quests, + Rooms: rooms, RecentEvents: recentEvents, PendingIntentCount: pendingIntents, Occupancy: occupancy, EntityOccupancy: entityOccupancy, + RoomOccupancy: roomOccupancy, NPCStates: npcStates, } } diff --git a/pkg/world/engine_test.go b/pkg/world/engine_test.go index c8fd761..04d3f5c 100644 --- a/pkg/world/engine_test.go +++ b/pkg/world/engine_test.go @@ -101,3 +101,41 @@ func TestApplyIntentInteractAppliesQuestAndResourceEffects(t *testing.T) { t.Fatalf("expected quest completion, got %+v", quest) } } + +func TestApplyIntentInteractAppliesEntityPlacementAndLocation(t *testing.T) { + engine := NewEngine() + state := DefaultWorldState() + state.Entities["bench"] = Entity{ + ID: "bench", + LocationID: "square", + State: map[string]interface{}{}, + } + npc := &NPCState{NPCID: "main", CurrentLocation: "square"} + delta := engine.ApplyIntent(&state, npc, ActionIntent{ + ActorID: "main", + Action: "interact", + TargetEntity: "bench", + ProposedEffects: map[string]interface{}{ + "entity_location": "commons", + "entity_placement": map[string]interface{}{ + "model": "entity.table", + "rotation_y": 1.57, + "offset_x": 0.8, + "offset_z": -0.4, + }, + }, + }) + if !delta.Applied { + t.Fatalf("expected interact to apply, got %+v", delta) + } + entity := state.Entities["bench"] + if entity.LocationID != "commons" { + t.Fatalf("expected entity moved to commons, got %+v", entity) + } + if entity.Placement == nil || entity.Placement.Model != "entity.table" { + t.Fatalf("expected placement model set, got %+v", entity.Placement) + } + if entity.State["rotation_y"] != 1.57 { + t.Fatalf("expected state rotation_y mirrored, got %+v", entity.State) + } +} diff --git a/pkg/world/store.go b/pkg/world/store.go index a2e24d9..78f9ddd 100644 --- a/pkg/world/store.go +++ b/pkg/world/store.go @@ -64,6 +64,7 @@ func DefaultWorldState() WorldState { }, GlobalFacts: map[string]interface{}{}, Entities: map[string]Entity{}, + Rooms: map[string]RoomState{}, ActiveQuests: map[string]QuestState{}, RecentEvents: []WorldEvent{}, } @@ -107,6 +108,9 @@ func (s *Store) LoadWorldState() (WorldState, error) { if state.Entities == nil { state.Entities = map[string]Entity{} } + if state.Rooms == nil { + state.Rooms = map[string]RoomState{} + } if state.ActiveQuests == nil { state.ActiveQuests = map[string]QuestState{} } diff --git a/pkg/world/types.go b/pkg/world/types.go index b5fe2f7..e25c13b 100644 --- a/pkg/world/types.go +++ b/pkg/world/types.go @@ -12,6 +12,7 @@ type Location struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Neighbors []string `json:"neighbors,omitempty"` + Model string `json:"model,omitempty"` } type Entity struct { @@ -19,9 +20,17 @@ type Entity struct { Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` LocationID string `json:"location_id,omitempty"` + Placement *EntityPlacement `json:"placement,omitempty"` State map[string]interface{} `json:"state,omitempty"` } +type EntityPlacement struct { + Model string `json:"model,omitempty"` + Scale [3]float64 `json:"scale,omitempty"` + Rotation [3]float64 `json:"rotation,omitempty"` + Offset [3]float64 `json:"offset,omitempty"` +} + type QuestState struct { ID string `json:"id"` Title string `json:"title,omitempty"` @@ -31,6 +40,21 @@ type QuestState struct { Summary string `json:"summary,omitempty"` } +type RoomState struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Kind string `json:"kind,omitempty"` + Status string `json:"status,omitempty"` + LocationID string `json:"location_id,omitempty"` + Model string `json:"model,omitempty"` + TaskSummary string `json:"task_summary,omitempty"` + LinkedQuestID string `json:"linked_quest_id,omitempty"` + AssignedNPCIDs []string `json:"assigned_npc_ids,omitempty"` + CreatedTick int64 `json:"created_tick,omitempty"` + UpdatedTick int64 `json:"updated_tick,omitempty"` + ReleaseOnFinish bool `json:"release_on_finish,omitempty"` +} + type WorldEvent struct { ID string `json:"id"` Type string `json:"type"` @@ -51,6 +75,7 @@ type WorldState struct { Locations map[string]Location `json:"locations,omitempty"` GlobalFacts map[string]interface{} `json:"global_facts,omitempty"` Entities map[string]Entity `json:"entities,omitempty"` + Rooms map[string]RoomState `json:"rooms,omitempty"` ActiveQuests map[string]QuestState `json:"active_quests,omitempty"` RecentEvents []WorldEvent `json:"recent_events,omitempty"` } @@ -81,6 +106,7 @@ type NPCState struct { PrivateMemorySummary string `json:"private_memory_summary,omitempty"` Status string `json:"status,omitempty"` LastActiveTick int64 `json:"last_active_tick,omitempty"` + CurrentRoomID string `json:"current_room_id,omitempty"` } type NPCBlueprint struct { @@ -124,6 +150,7 @@ type WorldTickRequest struct { ActorID string `json:"actor_id,omitempty"` UserInput string `json:"user_input,omitempty"` LocationID string `json:"location_id,omitempty"` + ForceStep bool `json:"force_step,omitempty"` CatchUpTicks int `json:"catch_up_ticks,omitempty"` MaxNPCPerTick int `json:"max_npc_per_tick,omitempty"` VisibleEvents []WorldEvent `json:"visible_events,omitempty"` @@ -142,12 +169,15 @@ type SnapshotSummary struct { SimTimeUnix int64 `json:"sim_time_unix,omitempty"` Player PlayerState `json:"player,omitempty"` Locations map[string]Location `json:"locations,omitempty"` + Entities map[string]Entity `json:"entities,omitempty"` NPCCount int `json:"npc_count,omitempty"` ActiveNPCs []string `json:"active_npcs,omitempty"` Quests []QuestState `json:"quests,omitempty"` + Rooms []RoomState `json:"rooms,omitempty"` RecentEvents []WorldEvent `json:"recent_events,omitempty"` PendingIntentCount int `json:"pending_intent_count,omitempty"` Occupancy map[string][]string `json:"occupancy,omitempty"` EntityOccupancy map[string][]string `json:"entity_occupancy,omitempty"` + RoomOccupancy map[string][]string `json:"room_occupancy,omitempty"` NPCStates map[string]NPCState `json:"npc_states,omitempty"` }