Add autonomous world runtime

This commit is contained in:
lpf
2026-03-16 21:06:46 +08:00
parent 5a6a455f43
commit 7f2ecfab58
13 changed files with 1342 additions and 13 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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 != "<nil>" {
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 != "<nil>" {
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

View File

@@ -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)

View File

@@ -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()) {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,
}
}

View File

@@ -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)
}
}

View File

@@ -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{}
}

View File

@@ -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"`
}