Add player actions to world core

This commit is contained in:
lpf
2026-03-16 12:04:55 +08:00
parent d9671892f3
commit 5bce2cfdef
7 changed files with 267 additions and 73 deletions

View File

@@ -42,51 +42,18 @@
"outcomes_title": "Execution Outcomes"
}
},
"router": {
"enabled": true,
"main_agent_id": "main",
"strategy": "rules_first",
"policy": {
"intent_max_input_chars": 1200,
"max_rounds_without_user": 200
},
"rules": [
{
"agent_id": "coder",
"keywords": ["代码", "实现", "修复", "重构", "bug", "debug", "implement", "refactor"]
},
{
"agent_id": "tester",
"keywords": ["测试", "回归", "验证", "test", "regression", "verify"]
}
],
"allow_direct_agent_chat": false,
"max_hops": 6,
"default_timeout_sec": 600,
"default_wait_reply": true,
"sticky_thread_owner": true
},
"communication": {
"mode": "mediated",
"persist_threads": true,
"persist_messages": true,
"max_messages_per_thread": 100,
"dead_letter_queue": true,
"default_message_ttl_sec": 86400
},
"subagents": {
"agents": {
"main": {
"enabled": true,
"type": "router",
"notify_main_policy": "final_only",
"display_name": "Main Agent",
"kind": "agent",
"type": "agent",
"display_name": "World Mind",
"role": "orchestrator",
"system_prompt_file": "agents/main/AGENT.md",
"description": "The world mind that arbitrates events, advances ticks, and responds to users as the game's core intelligence.",
"prompt_file": "agents/main/AGENT.md",
"memory_namespace": "main",
"accept_from": ["user", "coder", "tester"],
"can_talk_to": ["coder", "tester"],
"tools": {
"allowlist": ["sessions", "subagents", "memory_search", "repo_map"]
"allowlist": ["sessions", "world", "agent_profile", "memory_search", "repo_map"]
},
"runtime": {
"provider": "codex",
@@ -96,41 +63,51 @@
"max_parallel_runs": 4
}
},
"coder": {
"innkeeper": {
"enabled": true,
"type": "worker",
"notify_main_policy": "final_only",
"display_name": "Code Agent",
"role": "code",
"system_prompt_file": "agents/coder/AGENT.md",
"memory_namespace": "coder",
"accept_from": ["main", "tester"],
"can_talk_to": ["main", "tester"],
"kind": "npc",
"type": "npc",
"display_name": "Innkeeper",
"role": "host",
"description": "Keeps the tavern running, greets travelers, and passes along rumors.",
"persona": "Warm, observant, and quick to notice unusual visitors.",
"traits": ["welcoming", "practical", "well-informed"],
"faction": "tavern",
"home_location": "commons",
"default_goals": ["welcome travelers", "share useful rumors", "maintain order"],
"perception_scope": 1,
"world_tags": ["npc", "host", "rumors"],
"prompt_file": "agents/innkeeper/AGENT.md",
"memory_namespace": "innkeeper",
"tools": {
"allowlist": ["filesystem", "shell", "repo_map", "sessions"]
"allowlist": ["sessions", "world", "memory_search"]
},
"runtime": {
"provider": "openai",
"temperature": 0.2,
"max_retries": 1,
"retry_backoff_ms": 1000,
"max_task_chars": 20000,
"max_result_chars": 12000,
"max_parallel_runs": 2
}
},
"tester": {
"scout": {
"enabled": true,
"type": "worker",
"notify_main_policy": "on_blocked",
"display_name": "Test Agent",
"role": "test",
"system_prompt_file": "agents/tester/AGENT.md",
"memory_namespace": "tester",
"accept_from": ["main", "coder"],
"can_talk_to": ["main", "coder"],
"kind": "npc",
"type": "npc",
"display_name": "Scout",
"role": "watcher",
"description": "Watches nearby routes and returns with sightings and warnings.",
"persona": "Alert, terse, and mission-focused.",
"traits": ["curious", "fast", "loyal"],
"faction": "watch",
"home_location": "square",
"default_goals": ["patrol nearby paths", "report unusual events", "protect the commons"],
"perception_scope": 2,
"world_tags": ["npc", "patrol", "watch"],
"prompt_file": "agents/scout/AGENT.md",
"memory_namespace": "scout",
"tools": {
"allowlist": ["shell", "filesystem", "process_manager", "sessions"]
"allowlist": ["sessions", "world", "memory_search"]
},
"runtime": {
"provider": "anthropic",
@@ -142,11 +119,11 @@
},
"node.edge-dev.main": {
"enabled": true,
"type": "worker",
"kind": "agent",
"type": "agent",
"transport": "node",
"node_id": "edge-dev",
"parent_agent_id": "main",
"notify_main_policy": "internal_only",
"display_name": "Edge Dev Main Agent",
"role": "remote_main",
"memory_namespace": "node.edge-dev.main",

View File

@@ -64,6 +64,18 @@ func (al *AgentLoop) runtimeAdminHandlers() map[string]runtimeAdminHandler {
}
return map[string]interface{}{"message": out}, nil
},
"world_player_get": 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.PlayerGet()
},
"world_player_action": 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.PlayerAction(ctx, args)
},
"world_npc_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

@@ -62,6 +62,96 @@ func (wr *WorldRuntime) Tick(ctx context.Context, source string) (string, error)
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 {
@@ -436,15 +526,35 @@ func (wr *WorldRuntime) HandleUserInput(ctx context.Context, content, channel, c
if out, handled, err := wr.handleUserQuestInput(content); handled || err != nil {
return out, err
}
res, err := wr.advance(ctx, world.WorldTickRequest{
Source: "user",
UserInput: content,
LocationID: "commons",
})
out, err := wr.handlePlayerInput(ctx, content, "commons", "user")
if err != nil {
return "", err
}
return res.Text, nil
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) {
@@ -471,7 +581,7 @@ func (wr *WorldRuntime) advance(ctx context.Context, req world.WorldTickRequest)
var userEvent *world.WorldEvent
if strings.TrimSpace(req.UserInput) != "" {
wr.engine.NextTick(&state)
evt := wr.engine.BuildUserEvent(&state, req.UserInput, req.LocationID)
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 {
@@ -861,6 +971,16 @@ func firstNonEmpty(values ...string) string {
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))) {

View File

@@ -219,6 +219,60 @@ func TestWorldRuntimeSnapshotIncludesEntityOccupancyAfterInteract(t *testing.T)
}
}
func TestWorldRuntimePlayerDefaultsAndActions(t *testing.T) {
workspace := t.TempDir()
manager := tools.NewAgentManager(nil, workspace, nil)
store := manager.ProfileStore()
if _, err := store.Upsert(tools.AgentProfile{
AgentID: "keeper",
Name: "Keeper",
Kind: "npc",
Persona: "Greets visitors.",
HomeLocation: "commons",
DefaultGoals: []string{"watch the square"},
Status: "active",
}); err != nil {
t.Fatalf("profile upsert failed: %v", err)
}
manager.SetRunFunc(func(ctx context.Context, task *tools.AgentTask) (string, error) {
return `{"actor_id":"keeper","action":"speak","speech":"Welcome, traveler."}`, nil
})
runtime := NewWorldRuntime(workspace, store, tools.NewAgentDispatcher(manager), manager)
playerOut, err := runtime.PlayerGet()
if err != nil {
t.Fatalf("player get failed: %v", err)
}
data, _ := json.Marshal(playerOut)
if !strings.Contains(string(data), `"current_location":"commons"`) {
t.Fatalf("expected default player location commons, got %s", string(data))
}
moveOut, err := runtime.PlayerAction(context.Background(), map[string]interface{}{
"action": "move",
"location_id": "square",
})
if err != nil {
t.Fatalf("player move failed: %v", err)
}
moveData, _ := json.Marshal(moveOut)
if !strings.Contains(string(moveData), `"current_location":"square"`) {
t.Fatalf("expected moved player to square, got %s", string(moveData))
}
speakOut, err := runtime.PlayerAction(context.Background(), map[string]interface{}{
"action": "speak",
"target_npc_id": "keeper",
"prompt": "你好",
})
if err != nil {
t.Fatalf("player speak failed: %v", err)
}
if !strings.Contains(tools.MapStringArg(speakOut, "message"), "keeper") && !strings.Contains(tools.MapStringArg(speakOut, "message"), "Welcome") {
t.Fatalf("unexpected speak result: %#v", speakOut)
}
}
func TestHandleRuntimeAdminSnapshotIncludesWorld(t *testing.T) {
workspace := t.TempDir()
manager := tools.NewAgentManager(nil, workspace, nil)

View File

@@ -27,6 +27,12 @@ func (e *Engine) EnsureWorld(state *WorldState) {
if state.Locations == nil || len(state.Locations) == 0 {
state.Locations = DefaultWorldState().Locations
}
if strings.TrimSpace(state.Player.PlayerID) == "" {
state.Player = DefaultWorldState().Player
}
if strings.TrimSpace(state.Player.CurrentLocation) == "" {
state.Player.CurrentLocation = "commons"
}
if state.GlobalFacts == nil {
state.GlobalFacts = map[string]interface{}{}
}
@@ -81,14 +87,15 @@ func (e *Engine) NextTick(state *WorldState) int64 {
return state.Clock.Tick
}
func (e *Engine) BuildUserEvent(state *WorldState, input, locationID string) WorldEvent {
func (e *Engine) BuildUserEvent(state *WorldState, input, locationID, actorID string) WorldEvent {
e.EnsureWorld(state)
locationID = firstNonEmpty(locationID, "commons")
actorID = firstNonEmpty(actorID, "user")
return WorldEvent{
ID: fmt.Sprintf("evt-user-%d", time.Now().UnixNano()),
Type: "user_input",
Source: "user",
ActorID: "user",
ActorID: actorID,
LocationID: locationID,
Content: strings.TrimSpace(input),
Tick: state.Clock.Tick,
@@ -367,6 +374,7 @@ func (e *Engine) Snapshot(state WorldState, npcStates map[string]NPCState, recen
WorldID: state.WorldID,
Tick: state.Clock.Tick,
SimTimeUnix: state.Clock.SimTimeUnix,
Player: state.Player,
Locations: state.Locations,
NPCCount: len(npcStates),
ActiveNPCs: active,

View File

@@ -44,6 +44,12 @@ func DefaultWorldState() WorldState {
LastAdvance: now,
TickDuration: 30,
},
Player: PlayerState{
PlayerID: "player",
DisplayName: "Player",
CurrentLocation: "commons",
Status: "active",
},
Locations: map[string]Location{
"commons": {
ID: "commons",
@@ -89,6 +95,12 @@ func (s *Store) LoadWorldState() (WorldState, error) {
if state.Locations == nil {
state.Locations = DefaultWorldState().Locations
}
if strings.TrimSpace(state.Player.PlayerID) == "" {
state.Player = DefaultWorldState().Player
}
if strings.TrimSpace(state.Player.CurrentLocation) == "" {
state.Player.CurrentLocation = "commons"
}
if state.GlobalFacts == nil {
state.GlobalFacts = map[string]interface{}{}
}

View File

@@ -47,6 +47,7 @@ type WorldEvent struct {
type WorldState struct {
WorldID string `json:"world_id"`
Clock Clock `json:"clock"`
Player PlayerState `json:"player"`
Locations map[string]Location `json:"locations,omitempty"`
GlobalFacts map[string]interface{} `json:"global_facts,omitempty"`
Entities map[string]Entity `json:"entities,omitempty"`
@@ -54,6 +55,14 @@ type WorldState struct {
RecentEvents []WorldEvent `json:"recent_events,omitempty"`
}
type PlayerState struct {
PlayerID string `json:"player_id"`
DisplayName string `json:"display_name,omitempty"`
CurrentLocation string `json:"current_location,omitempty"`
Status string `json:"status,omitempty"`
LastActionTick int64 `json:"last_action_tick,omitempty"`
}
type GoalSet struct {
ShortTerm []string `json:"short_term,omitempty"`
LongTerm []string `json:"long_term,omitempty"`
@@ -112,6 +121,7 @@ type WorldDelta struct {
type WorldTickRequest struct {
Source string `json:"source,omitempty"`
ActorID string `json:"actor_id,omitempty"`
UserInput string `json:"user_input,omitempty"`
LocationID string `json:"location_id,omitempty"`
CatchUpTicks int `json:"catch_up_ticks,omitempty"`
@@ -130,6 +140,7 @@ type SnapshotSummary struct {
WorldID string `json:"world_id,omitempty"`
Tick int64 `json:"tick,omitempty"`
SimTimeUnix int64 `json:"sim_time_unix,omitempty"`
Player PlayerState `json:"player,omitempty"`
Locations map[string]Location `json:"locations,omitempty"`
NPCCount int `json:"npc_count,omitempty"`
ActiveNPCs []string `json:"active_npcs,omitempty"`