mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 06:47:30 +08:00
1676 lines
53 KiB
Go
1676 lines
53 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/YspCoder/clawgo/pkg/tools"
|
|
"github.com/YspCoder/clawgo/pkg/world"
|
|
)
|
|
|
|
type WorldRuntime struct {
|
|
store *world.Store
|
|
engine *world.Engine
|
|
profiles *tools.AgentProfileStore
|
|
dispatcher *tools.AgentDispatcher
|
|
manager *tools.AgentManager
|
|
maxCatchUp int
|
|
maxNPCPerTick int
|
|
tickMu sync.Mutex
|
|
}
|
|
|
|
func NewWorldRuntime(workspace string, profiles *tools.AgentProfileStore, dispatcher *tools.AgentDispatcher, manager *tools.AgentManager) *WorldRuntime {
|
|
return &WorldRuntime{
|
|
store: world.NewStore(workspace),
|
|
engine: world.NewEngine(),
|
|
profiles: profiles,
|
|
dispatcher: dispatcher,
|
|
manager: manager,
|
|
maxCatchUp: 3,
|
|
maxNPCPerTick: 8,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
wr.ensureStarterWorld(state)
|
|
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 (wr *WorldRuntime) ensureStarterWorld(state *world.WorldState) {
|
|
if state == nil {
|
|
return
|
|
}
|
|
if state.Entities == nil {
|
|
state.Entities = map[string]world.Entity{}
|
|
}
|
|
if _, ok := state.Entities["notice-board"]; !ok {
|
|
entity := world.Entity{
|
|
ID: "notice-board",
|
|
Name: "Notice Board",
|
|
Type: "landmark",
|
|
LocationID: "commons",
|
|
Placement: &world.EntityPlacement{
|
|
Model: "entity.landmark",
|
|
Scale: [3]float64{1, 1, 1},
|
|
Rotation: [3]float64{0, 0, 0},
|
|
Offset: [3]float64{0.6, 0, -0.2},
|
|
},
|
|
State: map[string]interface{}{"summary": "A place where the world mind leaves notes."},
|
|
}
|
|
applyEntityPlacementStateToState(&entity)
|
|
state.Entities[entity.ID] = entity
|
|
}
|
|
if _, ok := state.Entities["waystone"]; !ok {
|
|
entity := world.Entity{
|
|
ID: "waystone",
|
|
Name: "Waystone",
|
|
Type: "landmark",
|
|
LocationID: "square",
|
|
Placement: &world.EntityPlacement{
|
|
Model: "entity.landmark",
|
|
Scale: [3]float64{1.1, 1.1, 1.1},
|
|
Rotation: [3]float64{0, 0, 0},
|
|
Offset: [3]float64{-0.4, 0, 0.3},
|
|
},
|
|
State: map[string]interface{}{"summary": "A world marker that anchors the square."},
|
|
}
|
|
applyEntityPlacementStateToState(&entity)
|
|
state.Entities[entity.ID] = entity
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return nil, err
|
|
}
|
|
events, err := wr.store.Events(limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return wr.engine.Snapshot(state, npcStates, events, 0, limit), nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) Tick(ctx context.Context, source string) (string, error) {
|
|
res, err := wr.advance(ctx, world.WorldTickRequest{Source: source, ForceStep: true})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return res.Text, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) PlayerGet() (map[string]interface{}, error) {
|
|
state, _, err := wr.ensureState()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]interface{}{
|
|
"player": state.Player,
|
|
}, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) PlayerAction(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) {
|
|
state, _, err := wr.ensureState()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
action := strings.ToLower(strings.TrimSpace(tools.MapStringArg(args, "action")))
|
|
if action == "" {
|
|
return nil, fmt.Errorf("action is required")
|
|
}
|
|
player := state.Player
|
|
if strings.TrimSpace(player.PlayerID) == "" {
|
|
player = world.DefaultWorldState().Player
|
|
}
|
|
switch action {
|
|
case "move":
|
|
target := normalizeWorldID(firstNonEmpty(tools.MapStringArg(args, "location_id"), tools.MapStringArg(args, "target_location")))
|
|
if target == "" {
|
|
return nil, fmt.Errorf("location_id is required")
|
|
}
|
|
current := state.Locations[player.CurrentLocation]
|
|
if player.CurrentLocation != target && !runtimeContainsString(current.Neighbors, target) {
|
|
return nil, fmt.Errorf("location_not_reachable")
|
|
}
|
|
player.CurrentLocation = target
|
|
player.LastActionTick = state.Clock.Tick
|
|
state.Player = player
|
|
evt := world.WorldEvent{
|
|
ID: fmt.Sprintf("evt-player-move-%d", time.Now().UnixNano()),
|
|
Type: "player_move",
|
|
Source: "player",
|
|
ActorID: player.PlayerID,
|
|
LocationID: target,
|
|
Content: target,
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
wr.engine.AppendRecentEvent(&state, evt, 20)
|
|
if err := wr.store.SaveWorldState(state); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := wr.store.AppendWorldEvent(evt); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]interface{}{"ok": true, "message": fmt.Sprintf("moved to %s", target), "player": state.Player}, nil
|
|
case "speak":
|
|
target := strings.TrimSpace(firstNonEmpty(tools.MapStringArg(args, "target_npc_id"), tools.MapStringArg(args, "target_id")))
|
|
prompt := strings.TrimSpace(firstNonEmpty(tools.MapStringArg(args, "prompt"), tools.MapStringArg(args, "speech")))
|
|
content := prompt
|
|
if target != "" {
|
|
content = fmt.Sprintf("我在 %s 对 NPC %s 说:%s", firstNonEmpty(player.CurrentLocation, "commons"), target, firstNonEmpty(prompt, "你好。"))
|
|
}
|
|
return wr.handlePlayerInput(ctx, content, player.CurrentLocation, player.PlayerID)
|
|
case "interact":
|
|
target := strings.TrimSpace(firstNonEmpty(tools.MapStringArg(args, "target_entity_id"), tools.MapStringArg(args, "target_id")))
|
|
if target == "" {
|
|
return nil, fmt.Errorf("target_entity_id is required")
|
|
}
|
|
prompt := strings.TrimSpace(firstNonEmpty(tools.MapStringArg(args, "prompt"), tools.MapStringArg(args, "speech")))
|
|
content := fmt.Sprintf("我在 %s 与实体 %s 互动:%s", firstNonEmpty(player.CurrentLocation, "commons"), target, firstNonEmpty(prompt, "检查它并告诉我发生了什么。"))
|
|
return wr.handlePlayerInput(ctx, content, player.CurrentLocation, player.PlayerID)
|
|
case "quest_accept", "quest_progress", "quest_complete":
|
|
questID := strings.TrimSpace(firstNonEmpty(tools.MapStringArg(args, "quest_id"), tools.MapStringArg(args, "target_id")))
|
|
if questID == "" {
|
|
return nil, fmt.Errorf("quest_id is required")
|
|
}
|
|
var content string
|
|
switch action {
|
|
case "quest_accept":
|
|
content = fmt.Sprintf("接受任务 %s", questID)
|
|
case "quest_progress":
|
|
content = fmt.Sprintf("推进任务 %s %s", questID, strings.TrimSpace(tools.MapStringArg(args, "prompt")))
|
|
case "quest_complete":
|
|
content = fmt.Sprintf("完成任务 %s", questID)
|
|
}
|
|
return wr.handlePlayerInput(ctx, strings.TrimSpace(content), player.CurrentLocation, player.PlayerID)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported action: %s", action)
|
|
}
|
|
}
|
|
|
|
func (wr *WorldRuntime) NPCList() ([]map[string]interface{}, error) {
|
|
_, npcStates, err := wr.ensureState()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
profiles, err := wr.worldProfiles()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]map[string]interface{}, 0, len(profiles))
|
|
for _, profile := range profiles {
|
|
state := npcStates[profile.AgentID]
|
|
out = append(out, map[string]interface{}{
|
|
"npc_id": profile.AgentID,
|
|
"display_name": profile.Name,
|
|
"persona": profile.Persona,
|
|
"home_location": profile.HomeLocation,
|
|
"current_location": state.CurrentLocation,
|
|
"default_goals": append([]string(nil), profile.DefaultGoals...),
|
|
"status": state.Status,
|
|
"kind": profile.Kind,
|
|
"perception_scope": profile.PerceptionScope,
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return fmt.Sprint(out[i]["npc_id"]) < fmt.Sprint(out[j]["npc_id"])
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) NPCGet(id string) (map[string]interface{}, bool, error) {
|
|
_, npcStates, err := wr.ensureState()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
profile, ok, err := wr.profiles.Get(id)
|
|
if err != nil || !ok {
|
|
return nil, false, err
|
|
}
|
|
state, ok := npcStates[profile.AgentID]
|
|
if !ok {
|
|
return nil, false, nil
|
|
}
|
|
return map[string]interface{}{
|
|
"profile": profile,
|
|
"state": state,
|
|
}, true, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) EntityList() ([]map[string]interface{}, error) {
|
|
state, _, err := wr.ensureState()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]map[string]interface{}, 0, len(state.Entities))
|
|
for _, entity := range state.Entities {
|
|
out = append(out, map[string]interface{}{
|
|
"id": entity.ID,
|
|
"name": entity.Name,
|
|
"type": entity.Type,
|
|
"location_id": entity.LocationID,
|
|
"state": entity.State,
|
|
})
|
|
}
|
|
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) EntityGet(id string) (map[string]interface{}, bool, error) {
|
|
state, _, err := wr.ensureState()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
entity, ok := state.Entities[strings.TrimSpace(id)]
|
|
if !ok {
|
|
return nil, false, nil
|
|
}
|
|
return map[string]interface{}{
|
|
"id": entity.ID,
|
|
"name": entity.Name,
|
|
"type": entity.Type,
|
|
"location_id": entity.LocationID,
|
|
"state": entity.State,
|
|
}, true, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) WorldGet() (map[string]interface{}, error) {
|
|
state, _, err := wr.ensureState()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]interface{}{
|
|
"world_state": state,
|
|
}, 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 {
|
|
return nil, err
|
|
}
|
|
out := make([]map[string]interface{}, 0, len(events))
|
|
for _, evt := range events {
|
|
out = append(out, map[string]interface{}{
|
|
"id": evt.ID,
|
|
"type": evt.Type,
|
|
"actor_id": evt.ActorID,
|
|
"location_id": evt.LocationID,
|
|
"content": evt.Content,
|
|
"tick": evt.Tick,
|
|
"created_at": evt.CreatedAt,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) QuestList() ([]map[string]interface{}, error) {
|
|
state, _, err := wr.ensureState()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]map[string]interface{}, 0, len(state.ActiveQuests))
|
|
for _, quest := range state.ActiveQuests {
|
|
out = append(out, map[string]interface{}{
|
|
"id": quest.ID,
|
|
"title": quest.Title,
|
|
"status": quest.Status,
|
|
"owner_npc_id": quest.OwnerNPCID,
|
|
"participants": append([]string(nil), quest.Participants...),
|
|
"summary": quest.Summary,
|
|
})
|
|
}
|
|
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) QuestGet(id string) (map[string]interface{}, bool, error) {
|
|
state, _, err := wr.ensureState()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
quest, ok := state.ActiveQuests[strings.TrimSpace(id)]
|
|
if !ok {
|
|
return nil, false, nil
|
|
}
|
|
return map[string]interface{}{
|
|
"id": quest.ID,
|
|
"title": quest.Title,
|
|
"status": quest.Status,
|
|
"owner_npc_id": quest.OwnerNPCID,
|
|
"participants": append([]string(nil), quest.Participants...),
|
|
"summary": quest.Summary,
|
|
}, true, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) CreateQuest(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) {
|
|
_ = ctx
|
|
state, _, err := wr.ensureState()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
questID := normalizeWorldID(tools.MapStringArg(args, "id"))
|
|
if questID == "" {
|
|
questID = normalizeWorldID(tools.MapStringArg(args, "title"))
|
|
}
|
|
if questID == "" {
|
|
return nil, fmt.Errorf("id or title is required")
|
|
}
|
|
quest := state.ActiveQuests[questID]
|
|
quest.ID = questID
|
|
quest.Title = firstNonEmpty(strings.TrimSpace(tools.MapStringArg(args, "title")), quest.Title, questID)
|
|
quest.Status = firstNonEmpty(strings.TrimSpace(tools.MapStringArg(args, "status")), quest.Status, "open")
|
|
quest.OwnerNPCID = firstNonEmpty(strings.TrimSpace(tools.MapStringArg(args, "owner_npc_id")), quest.OwnerNPCID)
|
|
quest.Participants = append([]string(nil), tools.MapStringListArg(args, "participants")...)
|
|
quest.Summary = firstNonEmpty(strings.TrimSpace(tools.MapStringArg(args, "summary")), quest.Summary)
|
|
state.ActiveQuests[questID] = quest
|
|
if err := wr.store.SaveWorldState(state); err != nil {
|
|
return nil, err
|
|
}
|
|
evt := world.WorldEvent{
|
|
ID: fmt.Sprintf("evt-quest-%d", time.Now().UnixNano()),
|
|
Type: "quest_updated",
|
|
Source: "world",
|
|
ActorID: quest.OwnerNPCID,
|
|
Content: questID,
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
_ = wr.store.AppendWorldEvent(evt)
|
|
return map[string]interface{}{"quest_id": questID}, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) CreateNPC(ctx context.Context, args map[string]interface{}) (map[string]interface{}, error) {
|
|
_ = ctx
|
|
if wr == nil || wr.profiles == nil || wr.store == nil {
|
|
return nil, fmt.Errorf("world runtime not ready")
|
|
}
|
|
npcID := normalizeWorldID(tools.MapStringArg(args, "npc_id"))
|
|
if npcID == "" {
|
|
return nil, fmt.Errorf("npc_id is required")
|
|
}
|
|
persona := strings.TrimSpace(tools.MapStringArg(args, "persona"))
|
|
home := normalizeWorldID(tools.MapStringArg(args, "home_location"))
|
|
goals := tools.MapStringListArg(args, "default_goals")
|
|
if persona == "" || home == "" || len(goals) == 0 {
|
|
return nil, fmt.Errorf("persona, home_location, default_goals are required")
|
|
}
|
|
profile, err := wr.profiles.Upsert(tools.AgentProfile{
|
|
AgentID: npcID,
|
|
Name: strings.TrimSpace(tools.MapStringArg(args, "name")),
|
|
Kind: "npc",
|
|
Persona: persona,
|
|
HomeLocation: home,
|
|
DefaultGoals: goals,
|
|
Role: strings.TrimSpace(tools.MapStringArg(args, "role")),
|
|
MemoryNamespace: npcID,
|
|
Status: "active",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state, npcStates, err := wr.ensureState()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
npcStates[profile.AgentID] = wr.engine.EnsureNPCState(wr.profileBlueprint(*profile), npcStates[profile.AgentID])
|
|
if err := wr.store.SaveNPCStates(npcStates); err != nil {
|
|
return nil, err
|
|
}
|
|
evt := world.WorldEvent{
|
|
ID: fmt.Sprintf("evt-create-npc-%d", time.Now().UnixNano()),
|
|
Type: "npc_created",
|
|
Source: "world",
|
|
ActorID: profile.AgentID,
|
|
LocationID: home,
|
|
Content: profile.Name,
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
wr.engine.AppendRecentEvent(&state, evt, 20)
|
|
if err := wr.store.SaveWorldState(state); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := wr.store.AppendWorldEvent(evt); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]interface{}{"npc_id": profile.AgentID}, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) CreateEntity(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"), tools.MapStringArg(args, "name")))
|
|
locationID := normalizeWorldID(firstNonEmpty(tools.MapStringArg(args, "location_id"), tools.MapStringArg(args, "home_location")))
|
|
if entityID == "" || locationID == "" {
|
|
return nil, fmt.Errorf("entity_id and location_id are required")
|
|
}
|
|
if _, ok := state.Locations[locationID]; !ok {
|
|
return nil, fmt.Errorf("unknown location_id: %s", locationID)
|
|
}
|
|
entity := state.Entities[entityID]
|
|
entity.ID = entityID
|
|
entity.Name = firstNonEmpty(tools.MapStringArg(args, "name"), entity.Name, entityID)
|
|
entity.Type = firstNonEmpty(tools.MapStringArg(args, "entity_type"), entity.Type, "landmark")
|
|
entity.LocationID = locationID
|
|
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-%d", time.Now().UnixNano()),
|
|
Type: "entity_created",
|
|
Source: "world",
|
|
LocationID: 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) 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 == "" {
|
|
return "", false, nil
|
|
}
|
|
state, _, err := wr.ensureState()
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
lower := strings.ToLower(content)
|
|
switch {
|
|
case containsAnyQuestPhrase(lower, "list quests", "show quests", "任务列表", "查看任务", "有哪些任务"):
|
|
if len(state.ActiveQuests) == 0 {
|
|
return "当前没有活跃任务。", true, nil
|
|
}
|
|
items := make([]string, 0, len(state.ActiveQuests))
|
|
for _, quest := range state.ActiveQuests {
|
|
items = append(items, fmt.Sprintf("%s [%s]", firstNonEmpty(quest.Title, quest.ID), firstNonEmpty(quest.Status, "open")))
|
|
}
|
|
sort.Strings(items)
|
|
return "当前任务:\n" + strings.Join(items, "\n"), true, nil
|
|
case containsAnyQuestPhrase(lower, "accept quest", "take quest", "接受任务", "接取任务"):
|
|
id := resolveQuestReference(content, state)
|
|
if id == "" {
|
|
return "没有找到要接受的任务。", true, nil
|
|
}
|
|
quest := state.ActiveQuests[id]
|
|
quest.Status = "accepted"
|
|
state.ActiveQuests[id] = quest
|
|
if err := wr.saveQuestMutation(state, quest, "quest_accepted"); err != nil {
|
|
return "", true, err
|
|
}
|
|
return fmt.Sprintf("已接受任务:%s", firstNonEmpty(quest.Title, quest.ID)), true, nil
|
|
case containsAnyQuestPhrase(lower, "complete quest", "finish quest", "完成任务"):
|
|
id := resolveQuestReference(content, state)
|
|
if id == "" {
|
|
return "没有找到要完成的任务。", true, nil
|
|
}
|
|
quest := state.ActiveQuests[id]
|
|
quest.Status = "completed"
|
|
state.ActiveQuests[id] = quest
|
|
if err := wr.saveQuestMutation(state, quest, "quest_completed"); err != nil {
|
|
return "", true, err
|
|
}
|
|
return fmt.Sprintf("已完成任务:%s", firstNonEmpty(quest.Title, quest.ID)), true, nil
|
|
case containsAnyQuestPhrase(lower, "progress quest", "advance quest", "推进任务", "更新任务进度"):
|
|
id := resolveQuestReference(content, state)
|
|
if id == "" {
|
|
return "没有找到要推进的任务。", true, nil
|
|
}
|
|
quest := state.ActiveQuests[id]
|
|
quest.Status = "in_progress"
|
|
if summary := extractTailAfterQuestVerb(content); summary != "" {
|
|
quest.Summary = summary
|
|
}
|
|
state.ActiveQuests[id] = quest
|
|
if err := wr.saveQuestMutation(state, quest, "quest_progressed"); err != nil {
|
|
return "", true, err
|
|
}
|
|
return fmt.Sprintf("已推进任务:%s", firstNonEmpty(quest.Title, quest.ID)), true, nil
|
|
default:
|
|
return "", false, nil
|
|
}
|
|
}
|
|
|
|
func (wr *WorldRuntime) saveQuestMutation(state world.WorldState, quest world.QuestState, eventType string) error {
|
|
if err := wr.store.SaveWorldState(state); err != nil {
|
|
return err
|
|
}
|
|
evt := world.WorldEvent{
|
|
ID: fmt.Sprintf("evt-%s-%d", eventType, time.Now().UnixNano()),
|
|
Type: eventType,
|
|
Source: "user",
|
|
Content: quest.ID,
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
return wr.store.AppendWorldEvent(evt)
|
|
}
|
|
|
|
func (wr *WorldRuntime) HandleUserInput(ctx context.Context, content, channel, chatID string) (string, error) {
|
|
_ = channel
|
|
_ = chatID
|
|
if out, handled, err := wr.handleUserQuestInput(content); handled || err != nil {
|
|
return out, err
|
|
}
|
|
out, err := wr.handlePlayerInput(ctx, content, "commons", "user")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return tools.MapStringArg(out, "message"), nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) handlePlayerInput(ctx context.Context, content, locationID, actorID string) (map[string]interface{}, error) {
|
|
if out, handled, err := wr.handleUserQuestInput(content); handled || err != nil {
|
|
return map[string]interface{}{"ok": err == nil, "message": out}, err
|
|
}
|
|
res, err := wr.advance(ctx, world.WorldTickRequest{
|
|
Source: "player",
|
|
UserInput: content,
|
|
LocationID: locationID,
|
|
ActorID: actorID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state, _, stateErr := wr.ensureState()
|
|
if stateErr != nil {
|
|
return nil, stateErr
|
|
}
|
|
return map[string]interface{}{
|
|
"ok": true,
|
|
"message": res.Text,
|
|
"player": state.Player,
|
|
}, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
if catchUp > wr.maxCatchUp {
|
|
catchUp = wr.maxCatchUp
|
|
}
|
|
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
|
|
}
|
|
recentEvents = append(recentEvents, res.RecentEvents...)
|
|
}
|
|
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)
|
|
if err := wr.store.AppendWorldEvent(evt); err != nil {
|
|
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)
|
|
}
|
|
res, err := wr.runTick(ctx, &state, npcStates, visible, firstNonEmpty(req.Source, "user"))
|
|
if err != nil {
|
|
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
|
|
}
|
|
if err := wr.store.SaveWorldState(state); err != nil {
|
|
return world.RenderedResult{}, err
|
|
}
|
|
res.RecentEvents = recentEvents
|
|
if strings.TrimSpace(res.Text) == "" {
|
|
res.Text = wr.renderEvents(userEvent, recentEvents)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) runTick(ctx context.Context, state *world.WorldState, npcStates map[string]world.NPCState, seedEvents []world.WorldEvent, source string) (world.RenderedResult, error) {
|
|
profiles, err := wr.worldProfiles()
|
|
if err != nil {
|
|
return world.RenderedResult{}, err
|
|
}
|
|
maxNPC := wr.maxNPCPerTick
|
|
if maxNPC <= 0 {
|
|
maxNPC = len(profiles)
|
|
}
|
|
intents := make([]world.ActionIntent, 0, maxNPC)
|
|
appliedEvents := make([]world.WorldEvent, 0, maxNPC)
|
|
count := 0
|
|
for _, profile := range profiles {
|
|
if count >= maxNPC {
|
|
break
|
|
}
|
|
npcState := npcStates[profile.AgentID]
|
|
visible := wr.engine.VisibleEventsForNPC(*state, npcState, seedEvents, profile.PerceptionScope)
|
|
if len(visible) == 0 && !wr.shouldWakeNPC(profile, npcState, state.Clock.Tick) {
|
|
continue
|
|
}
|
|
intent, err := wr.decideNPCIntent(ctx, *state, profile, npcState, visible)
|
|
if err != nil {
|
|
return world.RenderedResult{}, err
|
|
}
|
|
intents = append(intents, intent)
|
|
delta := wr.engine.ApplyIntent(state, &npcState, intent)
|
|
if delta.Applied && strings.EqualFold(strings.TrimSpace(intent.Action), "delegate") && wr.manager != nil && strings.TrimSpace(intent.TargetAgent) != "" {
|
|
_, _ = wr.manager.SendAgentMessage(
|
|
profile.AgentID,
|
|
strings.TrimSpace(intent.TargetAgent),
|
|
"delegate",
|
|
firstNonEmpty(intent.Speech, intent.TargetEntity, "delegated task"),
|
|
"",
|
|
)
|
|
}
|
|
if !delta.Applied {
|
|
rejected := world.WorldEvent{
|
|
ID: fmt.Sprintf("evt-rejected-%d", time.Now().UnixNano()),
|
|
Type: "rejected_intent",
|
|
Source: source,
|
|
ActorID: intent.ActorID,
|
|
LocationID: npcState.CurrentLocation,
|
|
Content: delta.Reason,
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
wr.engine.AppendRecentEvent(state, rejected, 20)
|
|
if err := wr.store.AppendWorldEvent(rejected); err != nil {
|
|
return world.RenderedResult{}, err
|
|
}
|
|
appliedEvents = append(appliedEvents, rejected)
|
|
} else if delta.Event != nil {
|
|
npcStates[profile.AgentID] = npcState
|
|
wr.engine.AppendRecentEvent(state, *delta.Event, 20)
|
|
if err := wr.store.AppendWorldEvent(*delta.Event); err != nil {
|
|
return world.RenderedResult{}, err
|
|
}
|
|
appliedEvents = append(appliedEvents, *delta.Event)
|
|
}
|
|
count++
|
|
}
|
|
return world.RenderedResult{
|
|
Text: wr.renderEvents(nil, appliedEvents),
|
|
Tick: state.Clock.Tick,
|
|
Intents: intents,
|
|
RecentEvents: appliedEvents,
|
|
}, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) decideNPCIntent(ctx context.Context, state world.WorldState, profile tools.AgentProfile, npcState world.NPCState, visible []world.WorldEvent) (world.ActionIntent, error) {
|
|
if wr.dispatcher == nil {
|
|
return wr.fallbackIntent(profile, npcState, visible, state), nil
|
|
}
|
|
worldSnapshot := map[string]interface{}{
|
|
"tick": state.Clock.Tick,
|
|
"locations": state.Locations,
|
|
"rooms": state.Rooms,
|
|
"global_facts": state.GlobalFacts,
|
|
}
|
|
npcSnapshot := map[string]interface{}{
|
|
"npc_id": npcState.NPCID,
|
|
"display_name": profile.Name,
|
|
"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...),
|
|
}
|
|
visibleMaps := make([]map[string]interface{}, 0, len(visible))
|
|
for _, evt := range visible {
|
|
visibleMaps = append(visibleMaps, map[string]interface{}{
|
|
"id": evt.ID,
|
|
"type": evt.Type,
|
|
"actor_id": evt.ActorID,
|
|
"location_id": evt.LocationID,
|
|
"content": evt.Content,
|
|
"tick": evt.Tick,
|
|
})
|
|
}
|
|
taskText := wr.buildDecisionTask(profile, npcState, visible)
|
|
task, err := wr.dispatcher.DispatchTask(ctx, tools.AgentDispatchRequest{
|
|
Task: taskText,
|
|
RunKind: "world_npc",
|
|
AgentID: profile.AgentID,
|
|
Origin: &tools.OriginRef{Channel: "world", ChatID: "world"},
|
|
WorldDecision: &tools.WorldDecisionContext{
|
|
WorldTick: state.Clock.Tick,
|
|
WorldSnapshot: worldSnapshot,
|
|
NPCSnapshot: npcSnapshot,
|
|
VisibleEvents: visibleMaps,
|
|
IntentSchemaVersion: "v1",
|
|
},
|
|
})
|
|
if err != nil {
|
|
return wr.fallbackIntent(profile, npcState, visible, state), nil
|
|
}
|
|
reply, err := wr.dispatcher.WaitReply(ctx, task.ID, 100*time.Millisecond)
|
|
if err != nil {
|
|
return wr.fallbackIntent(profile, npcState, visible, state), nil
|
|
}
|
|
intent, err := parseWorldIntent(reply.Result)
|
|
if err != nil || strings.TrimSpace(intent.Action) == "" {
|
|
return wr.fallbackIntent(profile, npcState, visible, state), nil
|
|
}
|
|
if strings.TrimSpace(intent.ActorID) == "" {
|
|
intent.ActorID = profile.AgentID
|
|
}
|
|
return intent, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) fallbackIntent(profile tools.AgentProfile, npcState world.NPCState, visible []world.WorldEvent, state world.WorldState) world.ActionIntent {
|
|
intent := world.ActionIntent{
|
|
ActorID: profile.AgentID,
|
|
Action: "wait",
|
|
}
|
|
for _, evt := range visible {
|
|
if evt.Type == "user_input" && evt.LocationID == npcState.CurrentLocation {
|
|
speech := fmt.Sprintf("%s notices the user: %s", firstNonEmpty(profile.Name, profile.AgentID), strings.TrimSpace(evt.Content))
|
|
return world.ActionIntent{
|
|
ActorID: profile.AgentID,
|
|
Action: "speak",
|
|
Speech: speech,
|
|
InternalReasoningSummary: "responded to nearby user activity",
|
|
}
|
|
}
|
|
}
|
|
for _, goal := range npcState.Goals.LongTerm {
|
|
g := strings.ToLower(strings.TrimSpace(goal))
|
|
if strings.Contains(g, "patrol") {
|
|
loc := state.Locations[npcState.CurrentLocation]
|
|
if len(loc.Neighbors) > 0 {
|
|
return world.ActionIntent{
|
|
ActorID: profile.AgentID,
|
|
Action: "move",
|
|
TargetLocation: loc.Neighbors[0],
|
|
InternalReasoningSummary: "patrolling according to long-term goal",
|
|
}
|
|
}
|
|
}
|
|
if strings.Contains(g, "watch") || strings.Contains(g, "guard") || strings.Contains(g, "observe") {
|
|
return world.ActionIntent{
|
|
ActorID: profile.AgentID,
|
|
Action: "observe",
|
|
InternalReasoningSummary: "maintains awareness of the surroundings",
|
|
}
|
|
}
|
|
}
|
|
return intent
|
|
}
|
|
|
|
func (wr *WorldRuntime) buildDecisionTask(profile tools.AgentProfile, npcState world.NPCState, visible []world.WorldEvent) string {
|
|
payload := map[string]interface{}{
|
|
"npc_id": profile.AgentID,
|
|
"display_name": profile.Name,
|
|
"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,
|
|
"allowed_actions": []string{"move", "speak", "observe", "interact", "delegate", "wait"},
|
|
"response_contract": "return JSON object with actor_id, action, target_location, target_entity, target_agent, speech, internal_reasoning_summary, proposed_effects",
|
|
}
|
|
data, _ := json.Marshal(payload)
|
|
return string(data)
|
|
}
|
|
|
|
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
|
|
}
|
|
wr.engine.EnsureWorld(&state)
|
|
npcStates, err := wr.store.LoadNPCStates()
|
|
if err != nil {
|
|
return world.WorldState{}, nil, err
|
|
}
|
|
profiles, err := wr.worldProfiles()
|
|
if err != nil {
|
|
return world.WorldState{}, nil, err
|
|
}
|
|
for _, profile := range profiles {
|
|
current := npcStates[profile.AgentID]
|
|
next := wr.engine.EnsureNPCState(wr.profileBlueprint(profile), current)
|
|
npcStates[profile.AgentID] = next
|
|
}
|
|
wr.ensureStarterWorld(&state)
|
|
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
|
|
}
|
|
return state, npcStates, nil
|
|
}
|
|
|
|
func (wr *WorldRuntime) worldProfiles() ([]tools.AgentProfile, error) {
|
|
if wr == nil || wr.profiles == nil {
|
|
return nil, nil
|
|
}
|
|
items, err := wr.profiles.List()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]tools.AgentProfile, 0, len(items))
|
|
for _, item := range items {
|
|
if !strings.EqualFold(strings.TrimSpace(item.Status), "active") {
|
|
continue
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(item.AgentID), "main") {
|
|
out = append(out, worldMindProfile(item))
|
|
continue
|
|
}
|
|
if !isWorldNPCProfile(item) {
|
|
continue
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].AgentID < out[j].AgentID })
|
|
return out, nil
|
|
}
|
|
|
|
func isWorldNPCProfile(profile tools.AgentProfile) bool {
|
|
if !strings.EqualFold(strings.TrimSpace(profile.Kind), "npc") {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(profile.HomeLocation) != "" ||
|
|
strings.TrimSpace(profile.Persona) != "" ||
|
|
len(profile.DefaultGoals) > 0 ||
|
|
len(profile.WorldTags) > 0
|
|
}
|
|
|
|
func worldMindProfile(profile tools.AgentProfile) tools.AgentProfile {
|
|
out := profile
|
|
out.AgentID = "main"
|
|
if strings.TrimSpace(out.Name) == "" {
|
|
out.Name = "main"
|
|
}
|
|
out.Kind = "npc"
|
|
if strings.TrimSpace(out.Role) == "" {
|
|
out.Role = "world-mind"
|
|
}
|
|
if strings.TrimSpace(out.Persona) == "" {
|
|
out.Persona = "The world mind that maintains continuity, delegates work, and protects coherence."
|
|
}
|
|
if strings.TrimSpace(out.HomeLocation) == "" {
|
|
out.HomeLocation = "commons"
|
|
}
|
|
if len(out.DefaultGoals) == 0 {
|
|
out.DefaultGoals = []string{"maintain_world", "seed_story", "coordinate_npcs"}
|
|
}
|
|
if out.PerceptionScope <= 0 {
|
|
out.PerceptionScope = 2
|
|
}
|
|
if len(out.WorldTags) == 0 {
|
|
out.WorldTags = []string{"world_mind"}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (wr *WorldRuntime) profileBlueprint(profile tools.AgentProfile) world.NPCBlueprint {
|
|
return world.NPCBlueprint{
|
|
NPCID: profile.AgentID,
|
|
DisplayName: profile.Name,
|
|
Kind: profile.Kind,
|
|
Role: profile.Role,
|
|
Persona: profile.Persona,
|
|
Traits: append([]string(nil), profile.Traits...),
|
|
Faction: profile.Faction,
|
|
HomeLocation: firstNonEmpty(profile.HomeLocation, "commons"),
|
|
DefaultGoals: append([]string(nil), profile.DefaultGoals...),
|
|
PerceptionScope: profile.PerceptionScope,
|
|
ScheduleHint: profile.ScheduleHint,
|
|
WorldTags: append([]string(nil), profile.WorldTags...),
|
|
MemoryNamespace: profile.MemoryNamespace,
|
|
PromptFile: profile.PromptFile,
|
|
}
|
|
}
|
|
|
|
func (wr *WorldRuntime) shouldWakeNPC(profile tools.AgentProfile, state world.NPCState, tick int64) bool {
|
|
if tick == 0 {
|
|
return true
|
|
}
|
|
if len(state.Goals.LongTerm) == 0 {
|
|
return false
|
|
}
|
|
if state.LastActiveTick == 0 {
|
|
return true
|
|
}
|
|
return tick-state.LastActiveTick >= 2
|
|
}
|
|
|
|
func (wr *WorldRuntime) computeCatchUp(state world.WorldState) int {
|
|
if state.Clock.LastAdvance <= 0 || state.Clock.TickDuration <= 0 {
|
|
return 0
|
|
}
|
|
delta := time.Now().Unix() - state.Clock.LastAdvance
|
|
if delta <= state.Clock.TickDuration {
|
|
return 0
|
|
}
|
|
return int(delta / state.Clock.TickDuration)
|
|
}
|
|
|
|
func (wr *WorldRuntime) renderEvents(userEvent *world.WorldEvent, events []world.WorldEvent) string {
|
|
lines := make([]string, 0, len(events)+1)
|
|
if userEvent != nil && strings.TrimSpace(userEvent.Content) != "" {
|
|
lines = append(lines, "世界感知到你的行动:"+strings.TrimSpace(userEvent.Content))
|
|
}
|
|
for _, evt := range events {
|
|
switch evt.Type {
|
|
case "npc_speak":
|
|
lines = append(lines, fmt.Sprintf("%s 说:%s", evt.ActorID, strings.TrimSpace(evt.Content)))
|
|
case "npc_move":
|
|
lines = append(lines, fmt.Sprintf("%s 移动到了 %s", evt.ActorID, strings.TrimSpace(evt.LocationID)))
|
|
case "rejected_intent":
|
|
lines = append(lines, fmt.Sprintf("%s 的行动被世界拒绝:%s", evt.ActorID, strings.TrimSpace(evt.Content)))
|
|
case "npc_observe":
|
|
lines = append(lines, fmt.Sprintf("%s 正在观察局势", evt.ActorID))
|
|
case "npc_interact":
|
|
lines = append(lines, fmt.Sprintf("%s 发起了交互:%s", evt.ActorID, strings.TrimSpace(evt.Content)))
|
|
case "npc_delegate":
|
|
lines = append(lines, fmt.Sprintf("%s 发出了委托:%s", evt.ActorID, strings.TrimSpace(evt.Content)))
|
|
}
|
|
}
|
|
if len(lines) == 0 {
|
|
return "世界安静地推进了一拍。"
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func parseWorldIntent(raw string) (world.ActionIntent, error) {
|
|
var intent world.ActionIntent
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &intent); err != nil {
|
|
return world.ActionIntent{}, err
|
|
}
|
|
return intent, nil
|
|
}
|
|
|
|
func normalizeWorldID(in string) string {
|
|
in = strings.TrimSpace(strings.ToLower(in))
|
|
if in == "" {
|
|
return ""
|
|
}
|
|
var sb strings.Builder
|
|
for _, r := range in {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
sb.WriteRune(r)
|
|
case r >= '0' && r <= '9':
|
|
sb.WriteRune(r)
|
|
case r == '-' || r == '_' || r == '.':
|
|
sb.WriteRune(r)
|
|
case r == ' ':
|
|
sb.WriteRune('-')
|
|
}
|
|
}
|
|
return strings.Trim(sb.String(), "-_.")
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func runtimeContainsString(items []string, target string) bool {
|
|
target = strings.TrimSpace(target)
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item) == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsAnyQuestPhrase(text string, needles ...string) bool {
|
|
for _, needle := range needles {
|
|
if strings.Contains(text, strings.ToLower(strings.TrimSpace(needle))) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func resolveQuestReference(content string, state world.WorldState) string {
|
|
content = strings.ToLower(strings.TrimSpace(content))
|
|
for id, quest := range state.ActiveQuests {
|
|
if strings.Contains(content, strings.ToLower(id)) {
|
|
return id
|
|
}
|
|
if strings.TrimSpace(quest.Title) != "" && strings.Contains(content, strings.ToLower(quest.Title)) {
|
|
return id
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractTailAfterQuestVerb(content string) string {
|
|
raw := strings.TrimSpace(content)
|
|
lower := strings.ToLower(raw)
|
|
for _, marker := range []string{"推进任务", "更新任务进度", "progress quest", "advance quest"} {
|
|
idx := strings.Index(lower, strings.ToLower(marker))
|
|
if idx >= 0 {
|
|
out := strings.TrimSpace(raw[idx+len(marker):])
|
|
out = strings.Trim(out, " :,-")
|
|
return out
|
|
}
|
|
}
|
|
return ""
|
|
}
|