summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel
diff options
context:
space:
mode:
Diffstat (limited to 'src/LivingVillage.Kernel')
-rw-r--r--src/LivingVillage.Kernel/Sim.fs449
1 files changed, 373 insertions, 76 deletions
diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs
index 5fb56fd..0845abd 100644
--- a/src/LivingVillage.Kernel/Sim.fs
+++ b/src/LivingVillage.Kernel/Sim.fs
@@ -7,10 +7,6 @@ module Sim =
{ X: float32
Y: float32 }
- [<Struct>]
- type Avatar =
- { Pos: Vec2 }
-
type Input =
{ MoveX: float32
MoveY: float32 }
@@ -55,6 +51,33 @@ module Sim =
type RumorId =
| RumorId of int64
+ type DialogueIntent =
+ | SmallTalk
+ | AskHelp
+ | OfferTrade
+ | Joke
+ | Apologize
+ | Provoke
+
+ type DialogueResponse =
+ | Friendly
+ | Helpful
+ | Bargaining
+ | Amused
+ | Forgiving
+ | Hostile
+ | Reserved
+ | Refused
+ | Offended
+
+ type DialogueOutcome =
+ { Tick: int64
+ Actor: NpcId
+ Target: NpcId
+ Intent: DialogueIntent
+ Response: DialogueResponse
+ Valence: float32 }
+
type MemoryKind =
| Meal
| Rest
@@ -64,6 +87,7 @@ module Sim =
| Rumor of RumorId
| Bought of NpcId * ItemKind * int * float32
| Sold of NpcId * ItemKind * int * float32
+ | Dialogue of NpcId * DialogueIntent * DialogueResponse
type RumorEvent =
{ Id: RumorId
@@ -84,11 +108,51 @@ module Sim =
type InteractionKind =
| ChatInit of NpcId * NpcId
| TradeEvent of NpcId * NpcId * ItemKind * int * float32
+ | DialogueEvent of DialogueOutcome
type InteractionEvent =
{ Tick: int64
Kind: InteractionKind }
+ type NeedKind =
+ | HungerNeed
+ | EnergyNeed
+ | SocialNeed
+ | MoneyNeed
+
+ type NeedScore =
+ { Kind: NeedKind
+ Urgency: float32 }
+
+ type NeedsPanel =
+ { Needs: Needs
+ Ranked: NeedScore list }
+
+ type VisibleBounds =
+ { Min: Vec2
+ Max: Vec2 }
+
+ type Observation =
+ { Id: NpcId
+ Pos: Vec2
+ Needs: Needs
+ Ranked: NeedScore list
+ RecentMemory: MemoryEvent list }
+
+ type DialogueMenu =
+ { Target: NpcId
+ Options: DialogueIntent list }
+
+ type AnnalKind =
+ | DialogueAnnal of DialogueOutcome
+ | RumorAnnal of RumorEvent
+ | TradeAnnal of InteractionEvent
+
+ type AnnalEntry =
+ { Tick: int64
+ Kind: AnnalKind
+ Summary: string }
+
[<Struct>]
type Mind =
{ Needs: Needs
@@ -101,6 +165,11 @@ module Sim =
Memory: MemoryEvent list }
[<Struct>]
+ type Avatar =
+ { Pos: Vec2
+ Mind: Mind }
+
+ [<Struct>]
type Npc =
{ Id: NpcId
Pos: Vec2
@@ -116,7 +185,17 @@ module Sim =
NoHost: NoHost
Npcs: Npc[]
Events: InteractionEvent list
- Rumors: RumorEvent list }
+ Rumors: RumorEvent list
+ Annals: AnnalEntry list }
+
+ type DialogueFailure =
+ | DialogueTargetNotFound
+ | DialogueTargetOutOfRange
+ | DialogueTargetUnavailable
+
+ type DialogueResult =
+ | DialogueSucceeded of DialogueOutcome * World
+ | DialogueRejected of DialogueFailure * World
type ChatRequest =
{ Narrator: NpcId
@@ -182,8 +261,15 @@ module Sim =
let relationHalfLifeTicks = ticksPerDay // HALF_LIFE = 1 模拟日(修复单位错误:原 86400L 实为 24 模拟分钟;1 模拟日 = ticksPerDay = 5,184,000 tick)
let relationThreshold = 0.5f // |rel| > 0.5 视为有关系
+ let playerId = NpcId -1
+ let dialogueOptions = [ SmallTalk; AskHelp; OfferTrade; Joke; Apologize; Provoke ]
+ let maxAnnalEntries = 128
+
let clamp (v: float32) (lo: float32) (hi: float32) : float32 = if v < lo then lo elif v > hi then hi else v
+ let private appendAnnal (entry: AnnalEntry) (world: World) : World =
+ { world with Annals = (entry :: world.Annals) |> List.truncate maxAnnalEntries }
+
let hungerDecayPerTick = 0.0012f
let energyDecayPerTick = 0.0010f
let socialDecayPerTick = 0.0008f
@@ -221,12 +307,24 @@ module Sim =
| Rumor _ -> 0.1f
| Bought _ -> 0.3f
| Sold _ -> 0.3f
+ | Dialogue (_, _, response) ->
+ match response with
+ | Friendly -> 0.4f
+ | Helpful -> 0.5f
+ | Bargaining -> 0.2f
+ | Amused -> 0.35f
+ | Forgiving -> 0.45f
+ | Hostile -> -0.5f
+ | Reserved -> 0.0f
+ | Refused -> -0.1f
+ | Offended -> -0.4f
let recordMemory (tick: int64) (kind: MemoryKind) (mem: MemoryEvent list) : MemoryEvent list =
let updated = { Tick = tick; Kind = kind; Valence = valenceOfKind kind } :: mem
let isRelational (event: MemoryEvent) =
match event.Kind with
- | Chatted _ -> true
+ | Chatted _
+ | Dialogue _ -> true
| _ -> false
let relationalCount = updated |> List.sumBy (fun event -> if isRelational event then 1 else 0)
let relationalCapacity = min memoryCapacity relationalCount
@@ -288,6 +386,18 @@ module Sim =
|| n.Social < urgentThreshold
|| n.Money < urgentThreshold
+ let private rankNeeds (n: Needs) : NeedScore list =
+ [ { Kind = HungerNeed; Urgency = clamp (100.0f - n.Hunger) 0.0f 100.0f }
+ { Kind = EnergyNeed; Urgency = clamp (100.0f - n.Energy) 0.0f 100.0f }
+ { Kind = SocialNeed; Urgency = clamp (100.0f - n.Social) 0.0f 100.0f }
+ { Kind = MoneyNeed; Urgency = clamp (100.0f - n.Money) 0.0f 100.0f } ]
+ |> List.sortByDescending (fun score -> score.Urgency)
+
+ let needsPanel (world: World) : NeedsPanel =
+ let needs = world.Avatar.Mind.Needs
+ { Needs = needs
+ Ranked = rankNeeds needs }
+
let applyActionEffect (kind: NpcActionKind) (n: Needs) : Needs =
match kind with
| Eat -> { n with Hunger = n.Hunger + 40.0f; Money = n.Money - 5.0f }
@@ -326,14 +436,26 @@ module Sim =
EffectDone = false
HungerFlagged = false
Memory = [] } }
+ let avatar =
+ { Pos = { X = centerX; Y = centerY }
+ Mind =
+ { Needs = needs
+ Personality = personality
+ Action = Wander
+ Target = { X = centerX; Y = centerY }
+ ActionAge = 0L
+ EffectDone = false
+ HungerFlagged = false
+ Memory = [] } }
{ Tick = 0L
Time = 0.0
Rng = Rng.ofSeed seed
- Avatar = { Pos = { X = centerX; Y = centerY } }
+ Avatar = avatar
NoHost = { Reserved = 0UL }
Npcs = [| npc |]
Events = []
- Rumors = [] }
+ Rumors = []
+ Annals = [] }
let initialWorldN (seed: uint64) (count: int) : World =
let base_ = initialWorld seed
@@ -465,7 +587,13 @@ module Sim =
let event =
{ Tick = world.Tick
Kind = TradeEvent(request.Buyer, request.Seller, request.Item, request.Quantity, unitPrice) }
- TradeSucceeded { world with Npcs = newNpcs; Events = world.Events @ [ event ] }
+ let updated = { world with Npcs = newNpcs; Events = world.Events @ [ event ] }
+ let entry =
+ { Tick = world.Tick
+ Kind = TradeAnnal event
+ Summary = sprintf "trade tick=%d buyer=%A seller=%A item=%A quantity=%d price=%.2f"
+ world.Tick request.Buyer request.Seller request.Item request.Quantity unitPrice }
+ TradeSucceeded(appendAnnal entry updated)
let chatableForChat (n: Npc) : bool =
n.Mind.Action <> Sleep && n.Mind.Action <> Chat
@@ -520,6 +648,123 @@ module Sim =
max current id) -1L
RumorId(maxId + 1L)
+ let private dialogueAvailable (npc: Npc) : bool =
+ npc.Mind.Action <> Sleep && npc.Mind.Action <> Chat
+
+ let openDialogueMenu (world: World) : DialogueMenu option =
+ let nearby =
+ world.Npcs
+ |> Array.choose (fun npc ->
+ let dx = npc.Pos.X - world.Avatar.Pos.X
+ let dy = npc.Pos.Y - world.Avatar.Pos.Y
+ let distanceSq = dx * dx + dy * dy
+ if distanceSq <= chatRangeSq && dialogueAvailable npc then
+ Some(distanceSq, npc.Id)
+ else None)
+ |> Array.sortBy fst
+ nearby
+ |> Array.tryHead
+ |> Option.map (fun (_, target) ->
+ { Target = target
+ Options = dialogueOptions })
+
+ let private responseFor (intent: DialogueIntent) (personality: Personality) : DialogueResponse =
+ match intent with
+ | SmallTalk -> if personality.Extraversion >= 0.5f then Friendly else Reserved
+ | AskHelp -> if personality.Honesty >= 0.4f then Helpful else Refused
+ | OfferTrade -> Bargaining
+ | Joke -> if personality.Extraversion >= 0.5f then Amused else Reserved
+ | Apologize -> if personality.Aggression < 0.7f then Forgiving else Offended
+ | Provoke -> if personality.Aggression >= 0.5f then Hostile else Offended
+
+ let private dialogueSummary (outcome: DialogueOutcome) : string =
+ sprintf "dialogue tick=%d actor=%A target=%A intent=%A response=%A valence=%.2f"
+ outcome.Tick outcome.Actor outcome.Target outcome.Intent outcome.Response outcome.Valence
+
+ let private rumorSummary (rumor: RumorEvent) : string =
+ sprintf "rumor tick=%d source=%A narrator=%A receiver=%A depth=%d"
+ rumor.Tick rumor.Source rumor.Narrator rumor.Receiver rumor.Depth
+
+ let chooseDialogue (target: NpcId) (intent: DialogueIntent) (world: World) : DialogueResult =
+ match findNpcIndex target world.Npcs with
+ | None -> DialogueRejected(DialogueTargetNotFound, world)
+ | Some targetIndex ->
+ let targetNpc = world.Npcs.[targetIndex]
+ let dx = targetNpc.Pos.X - world.Avatar.Pos.X
+ let dy = targetNpc.Pos.Y - world.Avatar.Pos.Y
+ if dx * dx + dy * dy > chatRangeSq then
+ DialogueRejected(DialogueTargetOutOfRange, world)
+ elif not (dialogueAvailable targetNpc) then
+ DialogueRejected(DialogueTargetUnavailable, world)
+ else
+ let response = responseFor intent targetNpc.Mind.Personality
+ let outcome =
+ { Tick = world.Tick
+ Actor = playerId
+ Target = target
+ Intent = intent
+ Response = response
+ Valence = valenceOfKind (Dialogue(target, intent, response)) }
+ let rumor =
+ { Id = nextRumorId world
+ Tick = world.Tick
+ OriginTick = world.Tick
+ Source = playerId
+ Narrator = playerId
+ Receiver = target
+ Parent = None
+ Depth = 0
+ Strength = 1.0f }
+ let newNpcs = Array.copy world.Npcs
+ let targetMemory =
+ recordMemory world.Tick (Dialogue(playerId, intent, response)) targetNpc.Mind.Memory
+ |> recordMemory world.Tick (Rumor rumor.Id)
+ newNpcs.[targetIndex] <- { targetNpc with Mind = { targetNpc.Mind with Memory = targetMemory } }
+ let avatarMemory = recordMemory world.Tick (Dialogue(target, intent, response)) world.Avatar.Mind.Memory
+ let event = { Tick = world.Tick; Kind = DialogueEvent outcome }
+ let updated =
+ { world with
+ Avatar = { world.Avatar with Mind = { world.Avatar.Mind with Memory = avatarMemory } }
+ Npcs = newNpcs
+ Events = world.Events @ [ event ]
+ Rumors = rumor :: world.Rumors }
+ let withRumorAnnal =
+ appendAnnal
+ { Tick = world.Tick
+ Kind = RumorAnnal rumor
+ Summary = rumorSummary rumor }
+ updated
+ let withDialogueAnnal =
+ appendAnnal
+ { Tick = world.Tick
+ Kind = DialogueAnnal outcome
+ Summary = dialogueSummary outcome }
+ withRumorAnnal
+ DialogueSucceeded(outcome, withDialogueAnnal)
+
+ let annalText (world: World) : string =
+ let rows =
+ world.Annals
+ |> List.rev
+ |> List.map (fun entry -> sprintf "tick=%d %s" entry.Tick entry.Summary)
+ String.concat "\n" (sprintf "chronicle count=%d" world.Annals.Length :: rows)
+
+ let observeVisible (bounds: VisibleBounds) (world: World) : Observation list =
+ let inside (pos: Vec2) =
+ pos.X >= bounds.Min.X
+ && pos.X <= bounds.Max.X
+ && pos.Y >= bounds.Min.Y
+ && pos.Y <= bounds.Max.Y
+ world.Npcs
+ |> Array.toList
+ |> List.filter (fun npc -> inside npc.Pos)
+ |> List.map (fun npc ->
+ { Id = npc.Id
+ Pos = npc.Pos
+ Needs = npc.Mind.Needs
+ Ranked = rankNeeds npc.Mind.Needs
+ RecentMemory = List.truncate 8 npc.Mind.Memory })
+
let private rumorIsDuplicate (request: ChatRequest) (parent: RumorEvent option) (world: World) : bool =
let parentId = parent |> Option.map (fun rumor -> rumor.Id)
world.Rumors
@@ -531,74 +776,107 @@ module Sim =
&& world.Tick - rumor.Tick <= rumorFreshnessTicks
&& rumorStrengthAt world.Tick rumor >= rumorMinimumStrength)
+ type private ChatParticipant =
+ | AvatarParticipant
+ | NpcParticipant of int
+
+ let private findChatParticipant (id: NpcId) (world: World) : ChatParticipant option =
+ if id = playerId then
+ Some AvatarParticipant
+ else
+ findNpcIndex id world.Npcs |> Option.map NpcParticipant
+
+ let private participantPosition (participant: ChatParticipant) (world: World) : Vec2 =
+ match participant with
+ | AvatarParticipant -> world.Avatar.Pos
+ | NpcParticipant index -> world.Npcs.[index].Pos
+
+ let private participantChatable (participant: ChatParticipant) (world: World) : bool =
+ match participant with
+ | AvatarParticipant -> true
+ | NpcParticipant index -> chatableForChat world.Npcs.[index]
+
+ let private markChat (participant: ChatParticipant) (other: NpcId) (otherPos: Vec2) (world: World) : World =
+ match participant with
+ | AvatarParticipant ->
+ let memory = recordMemory world.Tick (Chatted other) world.Avatar.Mind.Memory
+ { world with Avatar = { world.Avatar with Mind = { world.Avatar.Mind with Memory = memory } } }
+ | NpcParticipant index ->
+ let newNpcs = Array.copy world.Npcs
+ let npc = newNpcs.[index]
+ let memory = recordMemory world.Tick (Chatted other) npc.Mind.Memory
+ newNpcs.[index] <-
+ { npc with
+ Mind =
+ { npc.Mind with
+ Action = Chat
+ Target = otherPos
+ ActionAge = 0L
+ EffectDone = false
+ Memory = memory } }
+ { world with Npcs = newNpcs }
+
let chat (request: ChatRequest) (world: World) : ChatResult =
- match findNpcIndex request.Narrator world.Npcs with
- | None -> ChatRejected(NarratorNotFound, world)
- | Some narratorIndex when request.Narrator = request.Receiver -> ChatRejected(ChatSameParticipant, world)
- | Some narratorIndex ->
- match findNpcIndex request.Receiver world.Npcs with
- | None -> ChatRejected(ReceiverNotFound, world)
- | Some receiverIndex ->
- let narrator = world.Npcs.[narratorIndex]
- let receiver = world.Npcs.[receiverIndex]
- if not (chatableForChat narrator) || not (chatableForChat receiver) then
- ChatRejected(ParticipantNotChatable, world)
+ match findChatParticipant request.Narrator world, findChatParticipant request.Receiver world with
+ | None, _ -> ChatRejected(NarratorNotFound, world)
+ | _, None -> ChatRejected(ReceiverNotFound, world)
+ | Some _, Some _ when request.Narrator = request.Receiver -> ChatRejected(ChatSameParticipant, world)
+ | Some narrator, Some receiver ->
+ if not (participantChatable narrator world) || not (participantChatable receiver world) then
+ ChatRejected(ParticipantNotChatable, world)
+ else
+ let parent = latestRumorFor request.Narrator world
+ let narratorPos = participantPosition narrator world
+ let receiverPos = participantPosition receiver world
+ let chatted =
+ world
+ |> markChat narrator request.Receiver receiverPos
+ |> markChat receiver request.Narrator narratorPos
+ if rumorIsDuplicate request parent world then
+ ChatSucceeded(None, chatted)
else
- let parent = latestRumorFor request.Narrator world
- let newNpcs = Array.copy world.Npcs
- let narratorMemory = recordMemory world.Tick (Chatted request.Receiver) narrator.Mind.Memory
- let receiverMemory = recordMemory world.Tick (Chatted request.Narrator) receiver.Mind.Memory
- newNpcs.[narratorIndex] <-
- { narrator with
- Mind =
- { narrator.Mind with
- Action = Chat
- Target = receiver.Pos
- ActionAge = 0L
- EffectDone = false
- Memory = narratorMemory } }
- newNpcs.[receiverIndex] <-
- { receiver with
- Mind =
- { receiver.Mind with
- Action = Chat
- Target = narrator.Pos
- ActionAge = 0L
- EffectDone = false
- Memory = receiverMemory } }
- let chatted = { world with Npcs = newNpcs }
- if rumorIsDuplicate request parent world then
- ChatSucceeded(None, chatted)
- else
- let rumor =
- match parent with
- | None ->
- { Id = nextRumorId world
- Tick = world.Tick
- OriginTick = world.Tick
- Source = request.Narrator
- Narrator = request.Narrator
- Receiver = request.Receiver
- Parent = None
- Depth = 0
- Strength = 1.0f }
- | Some parent ->
- { Id = nextRumorId world
- Tick = world.Tick
- OriginTick = parent.OriginTick
- Source = parent.Source
- Narrator = request.Narrator
- Receiver = request.Receiver
- Parent = Some parent.Id
- Depth = parent.Depth + 1
- Strength = rumorStrengthAt world.Tick parent }
- let receiverWithRumor =
- { newNpcs.[receiverIndex] with
- Mind =
- { newNpcs.[receiverIndex].Mind with
- Memory = recordMemory world.Tick (Rumor rumor.Id) newNpcs.[receiverIndex].Mind.Memory } }
- newNpcs.[receiverIndex] <- receiverWithRumor
- ChatSucceeded(Some rumor, { chatted with Npcs = newNpcs; Rumors = rumor :: world.Rumors })
+ let rumor =
+ match parent with
+ | None ->
+ { Id = nextRumorId world
+ Tick = world.Tick
+ OriginTick = world.Tick
+ Source = request.Narrator
+ Narrator = request.Narrator
+ Receiver = request.Receiver
+ Parent = None
+ Depth = 0
+ Strength = 1.0f }
+ | Some parent ->
+ { Id = nextRumorId world
+ Tick = world.Tick
+ OriginTick = parent.OriginTick
+ Source = parent.Source
+ Narrator = request.Narrator
+ Receiver = request.Receiver
+ Parent = Some parent.Id
+ Depth = parent.Depth + 1
+ Strength = rumorStrengthAt world.Tick parent }
+ let withReceiverRumor =
+ match receiver with
+ | AvatarParticipant ->
+ let memory = recordMemory world.Tick (Rumor rumor.Id) chatted.Avatar.Mind.Memory
+ { chatted with Avatar = { chatted.Avatar with Mind = { chatted.Avatar.Mind with Memory = memory } } }
+ | NpcParticipant index ->
+ let newNpcs = Array.copy chatted.Npcs
+ let npc = newNpcs.[index]
+ newNpcs.[index] <-
+ { npc with
+ Mind = { npc.Mind with Memory = recordMemory world.Tick (Rumor rumor.Id) npc.Mind.Memory } }
+ { chatted with Npcs = newNpcs }
+ let updated = { withReceiverRumor with Rumors = rumor :: world.Rumors }
+ ChatSucceeded(
+ Some rumor,
+ appendAnnal
+ { Tick = world.Tick
+ Kind = RumorAnnal rumor
+ Summary = rumorSummary rumor }
+ updated)
let rumorPath (world: World) (target: RumorId) : RumorEvent list =
let rec collect (visited: Set<RumorId>) (current: RumorId) (acc: RumorEvent list) =
@@ -729,6 +1007,23 @@ module Sim =
for i in 0 .. newNpcs.Length - 1 do
newNpcs.[i] <- stepNpc tick oldNpcs pending newNpcs.[i]
let queue = if pending.Count = 0 then world.Events else world.Events @ List.ofSeq pending
+ let avatarNeeds =
+ needsClamp
+ { Hunger = world.Avatar.Mind.Needs.Hunger - hungerDecayPerTick
+ Energy = world.Avatar.Mind.Needs.Energy - energyDecayPerTick
+ Social = world.Avatar.Mind.Needs.Social - socialDecayPerTick
+ Money = world.Avatar.Mind.Needs.Money - moneyDecayPerTick }
+ let avatarMemory, avatarFlagged =
+ if avatarNeeds.Hunger < urgentThreshold && not world.Avatar.Mind.HungerFlagged then
+ (recordMemory tick Hungry world.Avatar.Mind.Memory), true
+ else
+ world.Avatar.Mind.Memory, avatarNeeds.Hunger < urgentThreshold
+ let avatarMind =
+ { world.Avatar.Mind with
+ Needs = avatarNeeds
+ ActionAge = world.Avatar.Mind.ActionAge + 1L
+ HungerFlagged = avatarFlagged
+ Memory = avatarMemory }
let baseWorld =
{ world with
Tick = tick
@@ -737,7 +1032,8 @@ module Sim =
Avatar =
{ Pos =
{ X = clamp (world.Avatar.Pos.X + dx) 0.0f maxX
- Y = clamp (world.Avatar.Pos.Y + dy) 0.0f maxY } }
+ Y = clamp (world.Avatar.Pos.Y + dy) 0.0f maxY }
+ Mind = avatarMind }
Npcs = newNpcs }
let mutable nextWorld = baseWorld
for ev in queue do
@@ -747,6 +1043,7 @@ module Sim =
| ChatSucceeded (_, updated) -> nextWorld <- updated
| ChatRejected (_, _) -> ()
| TradeEvent _ -> ()
+ | DialogueEvent _ -> ()
{ nextWorld with Events = [] }
// ---- 关系网派生:rel(i,j) = sum(Chatted valence * exp(-ln2*(now-tick)/HALF_LIFE))(真半衰:age=HALF_LIFE 处权重恰 0.5)----