mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-06 08:57:29 +08:00
Add player actions to world core
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user