mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 18:07:36 +08:00
474 lines
13 KiB
Go
474 lines
13 KiB
Go
package world
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Engine struct{}
|
|
|
|
func NewEngine() *Engine {
|
|
return &Engine{}
|
|
}
|
|
|
|
func (e *Engine) EnsureWorld(state *WorldState) {
|
|
if state == nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(state.WorldID) == "" {
|
|
*state = DefaultWorldState()
|
|
return
|
|
}
|
|
if state.Clock.TickDuration <= 0 {
|
|
state.Clock.TickDuration = 30
|
|
}
|
|
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{}{}
|
|
}
|
|
if state.Entities == nil {
|
|
state.Entities = map[string]Entity{}
|
|
}
|
|
if state.ActiveQuests == nil {
|
|
state.ActiveQuests = map[string]QuestState{}
|
|
}
|
|
}
|
|
|
|
func (e *Engine) EnsureNPCState(blueprint NPCBlueprint, state NPCState) NPCState {
|
|
if strings.TrimSpace(state.NPCID) == "" {
|
|
state.NPCID = strings.TrimSpace(blueprint.NPCID)
|
|
}
|
|
if strings.TrimSpace(state.ProfileRef) == "" {
|
|
state.ProfileRef = strings.TrimSpace(blueprint.NPCID)
|
|
}
|
|
if strings.TrimSpace(state.CurrentLocation) == "" {
|
|
state.CurrentLocation = firstNonEmpty(blueprint.HomeLocation, "commons")
|
|
}
|
|
if len(state.Goals.LongTerm) == 0 && len(blueprint.DefaultGoals) > 0 {
|
|
state.Goals.LongTerm = append([]string(nil), blueprint.DefaultGoals...)
|
|
}
|
|
if state.Beliefs == nil {
|
|
state.Beliefs = map[string]string{}
|
|
}
|
|
if state.Relationships == nil {
|
|
state.Relationships = map[string]string{}
|
|
}
|
|
if state.Inventory == nil {
|
|
state.Inventory = map[string]int{}
|
|
}
|
|
if state.Assets == nil {
|
|
state.Assets = map[string]interface{}{}
|
|
}
|
|
if strings.TrimSpace(state.Status) == "" {
|
|
state.Status = "active"
|
|
}
|
|
return state
|
|
}
|
|
|
|
func (e *Engine) NextTick(state *WorldState) int64 {
|
|
e.EnsureWorld(state)
|
|
state.Clock.Tick++
|
|
now := time.Now().Unix()
|
|
if state.Clock.SimTimeUnix <= 0 {
|
|
state.Clock.SimTimeUnix = now
|
|
}
|
|
state.Clock.SimTimeUnix += maxInt64(state.Clock.TickDuration, 1)
|
|
state.Clock.LastAdvance = now
|
|
return state.Clock.Tick
|
|
}
|
|
|
|
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: actorID,
|
|
LocationID: locationID,
|
|
Content: strings.TrimSpace(input),
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
}
|
|
|
|
func (e *Engine) VisibleEventsForNPC(state WorldState, npc NPCState, events []WorldEvent, scope int) []WorldEvent {
|
|
out := make([]WorldEvent, 0, len(events))
|
|
location := strings.TrimSpace(npc.CurrentLocation)
|
|
for _, evt := range events {
|
|
if evt.LocationID == "" || evt.ActorID == npc.NPCID || evt.Source == "world" {
|
|
out = append(out, evt)
|
|
continue
|
|
}
|
|
if locationVisibleWithinScope(state, location, strings.TrimSpace(evt.LocationID), scope) {
|
|
out = append(out, evt)
|
|
continue
|
|
}
|
|
if scope > 0 && len(evt.VisibleTo) > 0 {
|
|
for _, item := range evt.VisibleTo {
|
|
if strings.TrimSpace(item) == npc.NPCID {
|
|
out = append(out, evt)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (e *Engine) ApplyIntent(state *WorldState, npc *NPCState, intent ActionIntent) WorldDelta {
|
|
e.EnsureWorld(state)
|
|
delta := WorldDelta{Applied: false, Intent: intent}
|
|
if npc == nil {
|
|
delta.Reason = "npc_state_missing"
|
|
return delta
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(intent.Action)) {
|
|
case "wait", "":
|
|
delta.Applied = true
|
|
delta.Reason = "noop"
|
|
delta.Event = &WorldEvent{
|
|
ID: fmt.Sprintf("evt-wait-%d", time.Now().UnixNano()),
|
|
Type: "npc_wait",
|
|
Source: "npc",
|
|
ActorID: npc.NPCID,
|
|
LocationID: npc.CurrentLocation,
|
|
Content: "waits",
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
case "move":
|
|
target := strings.TrimSpace(intent.TargetLocation)
|
|
if target == "" {
|
|
delta.Reason = "missing_target_location"
|
|
return delta
|
|
}
|
|
current := state.Locations[npc.CurrentLocation]
|
|
if !containsString(current.Neighbors, target) && npc.CurrentLocation != target {
|
|
delta.Reason = "location_not_reachable"
|
|
return delta
|
|
}
|
|
npc.CurrentLocation = target
|
|
npc.LastActiveTick = state.Clock.Tick
|
|
delta.Applied = true
|
|
delta.NPCStateChanged = true
|
|
delta.Event = &WorldEvent{
|
|
ID: fmt.Sprintf("evt-move-%d", time.Now().UnixNano()),
|
|
Type: "npc_move",
|
|
Source: "npc",
|
|
ActorID: npc.NPCID,
|
|
LocationID: target,
|
|
Content: target,
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
case "speak":
|
|
delta.Applied = true
|
|
npc.LastActiveTick = state.Clock.Tick
|
|
delta.Event = &WorldEvent{
|
|
ID: fmt.Sprintf("evt-speak-%d", time.Now().UnixNano()),
|
|
Type: "npc_speak",
|
|
Source: "npc",
|
|
ActorID: npc.NPCID,
|
|
LocationID: npc.CurrentLocation,
|
|
Content: strings.TrimSpace(intent.Speech),
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
case "observe":
|
|
if npc.Beliefs == nil {
|
|
npc.Beliefs = map[string]string{}
|
|
}
|
|
if summary := strings.TrimSpace(intent.InternalReasoningSummary); summary != "" {
|
|
npc.Beliefs[fmt.Sprintf("tick-%d", state.Clock.Tick)] = summary
|
|
}
|
|
npc.LastActiveTick = state.Clock.Tick
|
|
delta.Applied = true
|
|
delta.NPCStateChanged = true
|
|
delta.Event = &WorldEvent{
|
|
ID: fmt.Sprintf("evt-observe-%d", time.Now().UnixNano()),
|
|
Type: "npc_observe",
|
|
Source: "npc",
|
|
ActorID: npc.NPCID,
|
|
LocationID: npc.CurrentLocation,
|
|
Content: strings.TrimSpace(intent.InternalReasoningSummary),
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
case "interact":
|
|
targetEntity := strings.TrimSpace(intent.TargetEntity)
|
|
if targetEntity == "" {
|
|
delta.Reason = "missing_target_entity"
|
|
return delta
|
|
}
|
|
entity, ok := state.Entities[targetEntity]
|
|
if !ok {
|
|
delta.Reason = "entity_not_found"
|
|
return delta
|
|
}
|
|
if !locationVisibleWithinScope(*state, npc.CurrentLocation, entity.LocationID, 1) {
|
|
delta.Reason = "entity_not_reachable"
|
|
return delta
|
|
}
|
|
if entity.State == nil {
|
|
entity.State = map[string]interface{}{}
|
|
}
|
|
entity.State["last_actor"] = npc.NPCID
|
|
entity.State["last_interaction_tick"] = state.Clock.Tick
|
|
if count, ok := entity.State["interaction_count"].(float64); ok {
|
|
entity.State["interaction_count"] = count + 1
|
|
} else if count, ok := entity.State["interaction_count"].(int); ok {
|
|
entity.State["interaction_count"] = count + 1
|
|
} else {
|
|
entity.State["interaction_count"] = 1
|
|
}
|
|
if effect := strings.TrimSpace(intent.Speech); effect != "" {
|
|
entity.State["last_effect"] = effect
|
|
}
|
|
e.applyProposedEffects(state, npc, intent, &entity)
|
|
state.Entities[targetEntity] = entity
|
|
delta.Applied = true
|
|
npc.LastActiveTick = state.Clock.Tick
|
|
delta.NPCStateChanged = true
|
|
delta.Event = &WorldEvent{
|
|
ID: fmt.Sprintf("evt-interact-%d", time.Now().UnixNano()),
|
|
Type: "npc_interact",
|
|
Source: "npc",
|
|
ActorID: npc.NPCID,
|
|
LocationID: npc.CurrentLocation,
|
|
Content: firstNonEmpty(targetEntity, intent.Speech, "interacts"),
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
case "delegate":
|
|
delta.Applied = true
|
|
npc.LastActiveTick = state.Clock.Tick
|
|
delta.Event = &WorldEvent{
|
|
ID: fmt.Sprintf("evt-delegate-%d", time.Now().UnixNano()),
|
|
Type: "npc_delegate",
|
|
Source: "npc",
|
|
ActorID: npc.NPCID,
|
|
LocationID: npc.CurrentLocation,
|
|
Content: firstNonEmpty(intent.TargetAgent, intent.Speech, "delegates"),
|
|
Tick: state.Clock.Tick,
|
|
CreatedAt: time.Now().UnixMilli(),
|
|
}
|
|
default:
|
|
delta.Reason = "unsupported_action"
|
|
}
|
|
return delta
|
|
}
|
|
|
|
func (e *Engine) applyProposedEffects(state *WorldState, npc *NPCState, intent ActionIntent, entity *Entity) {
|
|
if state == nil || intent.ProposedEffects == nil {
|
|
return
|
|
}
|
|
if entity != nil {
|
|
if raw, ok := intent.ProposedEffects["resource_delta"].(map[string]interface{}); ok {
|
|
resources := map[string]int{}
|
|
if existing, ok := entity.State["resources"].(map[string]int); ok {
|
|
for k, v := range existing {
|
|
resources[k] = v
|
|
}
|
|
}
|
|
if existing, ok := entity.State["resources"].(map[string]interface{}); ok {
|
|
for k, v := range existing {
|
|
resources[k] = int(numberToFloat(v))
|
|
}
|
|
}
|
|
for key, value := range raw {
|
|
resources[strings.TrimSpace(key)] += int(numberToFloat(value))
|
|
}
|
|
entity.State["resources"] = resources
|
|
}
|
|
if raw, ok := intent.ProposedEffects["entity_state"].(map[string]interface{}); ok {
|
|
for key, value := range raw {
|
|
entity.State[strings.TrimSpace(key)] = value
|
|
}
|
|
}
|
|
}
|
|
if raw, ok := intent.ProposedEffects["quest_update"].(map[string]interface{}); ok {
|
|
questID := strings.TrimSpace(fmt.Sprint(raw["id"]))
|
|
if questID != "" {
|
|
quest := state.ActiveQuests[questID]
|
|
quest.ID = questID
|
|
if title := strings.TrimSpace(fmt.Sprint(raw["title"])); title != "" {
|
|
quest.Title = title
|
|
}
|
|
if status := strings.TrimSpace(fmt.Sprint(raw["status"])); status != "" {
|
|
quest.Status = status
|
|
}
|
|
if summary := strings.TrimSpace(fmt.Sprint(raw["summary"])); summary != "" {
|
|
quest.Summary = summary
|
|
}
|
|
if owner := strings.TrimSpace(fmt.Sprint(raw["owner_npc_id"])); owner != "" {
|
|
quest.OwnerNPCID = owner
|
|
} else if npc != nil && strings.TrimSpace(quest.OwnerNPCID) == "" {
|
|
quest.OwnerNPCID = npc.NPCID
|
|
}
|
|
if participants, ok := raw["participants"].([]interface{}); ok {
|
|
quest.Participants = make([]string, 0, len(participants))
|
|
for _, item := range participants {
|
|
if v := strings.TrimSpace(fmt.Sprint(item)); v != "" {
|
|
quest.Participants = append(quest.Participants, v)
|
|
}
|
|
}
|
|
}
|
|
state.ActiveQuests[questID] = quest
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *Engine) AppendRecentEvent(state *WorldState, evt WorldEvent, maxRecent int) {
|
|
if state == nil {
|
|
return
|
|
}
|
|
state.RecentEvents = append([]WorldEvent{evt}, state.RecentEvents...)
|
|
if maxRecent > 0 && len(state.RecentEvents) > maxRecent {
|
|
state.RecentEvents = state.RecentEvents[:maxRecent]
|
|
}
|
|
}
|
|
|
|
func (e *Engine) Snapshot(state WorldState, npcStates map[string]NPCState, recentEvents []WorldEvent, pendingIntents int, limit int) SnapshotSummary {
|
|
if limit > 0 && len(recentEvents) > limit {
|
|
recentEvents = recentEvents[:limit]
|
|
}
|
|
active := make([]string, 0, len(npcStates))
|
|
quests := make([]QuestState, 0, len(state.ActiveQuests))
|
|
occupancy := map[string][]string{}
|
|
entityOccupancy := map[string][]string{}
|
|
for id, npc := range npcStates {
|
|
active = append(active, id)
|
|
loc := firstNonEmpty(npc.CurrentLocation, "commons")
|
|
occupancy[loc] = append(occupancy[loc], id)
|
|
}
|
|
for _, quest := range state.ActiveQuests {
|
|
quests = append(quests, quest)
|
|
}
|
|
for id, entity := range state.Entities {
|
|
loc := firstNonEmpty(entity.LocationID, "commons")
|
|
entityOccupancy[loc] = append(entityOccupancy[loc], id)
|
|
}
|
|
sort.Strings(active)
|
|
sort.Slice(quests, func(i, j int) bool {
|
|
return firstNonEmpty(quests[i].ID, quests[i].Title) < firstNonEmpty(quests[j].ID, quests[j].Title)
|
|
})
|
|
for key := range occupancy {
|
|
sort.Strings(occupancy[key])
|
|
}
|
|
for key := range entityOccupancy {
|
|
sort.Strings(entityOccupancy[key])
|
|
}
|
|
return SnapshotSummary{
|
|
WorldID: state.WorldID,
|
|
Tick: state.Clock.Tick,
|
|
SimTimeUnix: state.Clock.SimTimeUnix,
|
|
Player: state.Player,
|
|
Locations: state.Locations,
|
|
NPCCount: len(npcStates),
|
|
ActiveNPCs: active,
|
|
Quests: quests,
|
|
RecentEvents: recentEvents,
|
|
PendingIntentCount: pendingIntents,
|
|
Occupancy: occupancy,
|
|
EntityOccupancy: entityOccupancy,
|
|
NPCStates: npcStates,
|
|
}
|
|
}
|
|
|
|
func locationVisibleWithinScope(state WorldState, fromLocation, targetLocation string, scope int) bool {
|
|
fromLocation = strings.TrimSpace(fromLocation)
|
|
targetLocation = strings.TrimSpace(targetLocation)
|
|
if targetLocation == "" || fromLocation == "" {
|
|
return targetLocation == fromLocation
|
|
}
|
|
if fromLocation == targetLocation {
|
|
return true
|
|
}
|
|
if scope <= 0 {
|
|
return false
|
|
}
|
|
visited := map[string]struct{}{fromLocation: {}}
|
|
frontier := []string{fromLocation}
|
|
for depth := 0; depth < scope; depth++ {
|
|
next := make([]string, 0)
|
|
for _, current := range frontier {
|
|
loc, ok := state.Locations[current]
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, neighbor := range loc.Neighbors {
|
|
neighbor = strings.TrimSpace(neighbor)
|
|
if neighbor == "" {
|
|
continue
|
|
}
|
|
if neighbor == targetLocation {
|
|
return true
|
|
}
|
|
if _, seen := visited[neighbor]; seen {
|
|
continue
|
|
}
|
|
visited[neighbor] = struct{}{}
|
|
next = append(next, neighbor)
|
|
}
|
|
}
|
|
frontier = next
|
|
if len(frontier) == 0 {
|
|
break
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsString(items []string, target string) bool {
|
|
target = strings.TrimSpace(target)
|
|
for _, item := range items {
|
|
if strings.TrimSpace(item) == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func maxInt64(v, min int64) int64 {
|
|
if v < min {
|
|
return min
|
|
}
|
|
return v
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func numberToFloat(v interface{}) float64 {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return float64(n)
|
|
case int64:
|
|
return float64(n)
|
|
case float64:
|
|
return n
|
|
case float32:
|
|
return float64(n)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|