mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-12 07:27:28 +08:00
Add player actions to world core
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{}{}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user