summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-20 12:18:38 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-20 12:18:38 +0800
commit4f8aca922e6cf47dce95aab79541065d487d5272 (patch)
tree3d0e259440912ccb7ab62b910c576e928326fa96 /src/LivingVillage.Kernel
parent23a9235f29e92adb51c3bf1a71f92cd8434e0939 (diff)
downloadliving-village-4f8aca922e6cf47dce95aab79541065d487d5272.tar.gz
feat(m4b): 增加可追踪谣言传播与回放
[变更性质] - 本提交新增 M4b 谣言传播与确定性验收能力,不是单纯缺陷修复。 [新增功能] - 支持带来源、父节点、深度、强度和路径的谣言传播、去重、衰减与过期处理。 - Headless 支持稳定谣言 dump、replay,以及与 batch 模式的互斥校验。 [实现方案] - 通过不可变 World 更新和失败原子返回保持模拟确定性。 - 新增多跳传播、重复转述、过期父节点、非法参与者和稳定 trace 测试。 [影响范围] - 影响 Kernel、Headless 和 Kernel 测试;不包含 M5/M6 或无关配置与文档改动。
Diffstat (limited to 'src/LivingVillage.Kernel')
-rw-r--r--src/LivingVillage.Kernel/Sim.fs256
1 files changed, 205 insertions, 51 deletions
diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs
index a2944e5..5fb56fd 100644
--- a/src/LivingVillage.Kernel/Sim.fs
+++ b/src/LivingVillage.Kernel/Sim.fs
@@ -51,15 +51,31 @@ module Sim =
| Work
| Chat
+ [<Struct>]
+ type RumorId =
+ | RumorId of int64
+
type MemoryKind =
| Meal
| Rest
| Pay
| Hungry
| Chatted of NpcId
+ | Rumor of RumorId
| Bought of NpcId * ItemKind * int * float32
| Sold of NpcId * ItemKind * int * float32
+ type RumorEvent =
+ { Id: RumorId
+ Tick: int64
+ OriginTick: int64
+ Source: NpcId
+ Narrator: NpcId
+ Receiver: NpcId
+ Parent: RumorId option
+ Depth: int
+ Strength: float32 }
+
type MemoryEvent =
{ Tick: int64
Kind: MemoryKind
@@ -99,7 +115,22 @@ module Sim =
Avatar: Avatar
NoHost: NoHost
Npcs: Npc[]
- Events: InteractionEvent list }
+ Events: InteractionEvent list
+ Rumors: RumorEvent list }
+
+ type ChatRequest =
+ { Narrator: NpcId
+ Receiver: NpcId }
+
+ type ChatFailure =
+ | NarratorNotFound
+ | ReceiverNotFound
+ | ChatSameParticipant
+ | ParticipantNotChatable
+
+ type ChatResult =
+ | ChatSucceeded of RumorEvent option * World
+ | ChatRejected of ChatFailure * World
type TradeRequest =
{ Buyer: NpcId
@@ -143,6 +174,9 @@ module Sim =
let chatTicks = 300L
let chatSocialRestore = 40.0f
let chatValence = 0.3f
+ let rumorFreshnessTicks = 3L * ticksPerDay
+ let rumorHalfLifeTicks = ticksPerDay
+ let rumorMinimumStrength = 0.125f
// 关系网派生(纯函数;仅在 dump/视图打开时调用,不进 step 热路径)
let relationHalfLifeTicks = ticksPerDay // HALF_LIFE = 1 模拟日(修复单位错误:原 86400L 实为 24 模拟分钟;1 模拟日 = ticksPerDay = 5,184,000 tick)
@@ -184,6 +218,7 @@ module Sim =
| Pay -> 0.2f
| Hungry -> -0.4f
| Chatted _ -> chatValence
+ | Rumor _ -> 0.1f
| Bought _ -> 0.3f
| Sold _ -> 0.3f
@@ -297,7 +332,8 @@ module Sim =
Avatar = { Pos = { X = centerX; Y = centerY } }
NoHost = { Reserved = 0UL }
Npcs = [| npc |]
- Events = [] }
+ Events = []
+ Rumors = [] }
let initialWorldN (seed: uint64) (count: int) : World =
let base_ = initialWorld seed
@@ -457,6 +493,154 @@ module Sim =
| Some partner -> Some partner
| None -> pick (fun _ -> true)
+ let rumorDecayWeight (nowTick: int64) (eventTick: int64) : float32 =
+ let age = max 0L (nowTick - eventTick)
+ float32 (exp (-(log 2.0) * float age / float rumorHalfLifeTicks))
+
+ let rumorStrengthAt (nowTick: int64) (rumor: RumorEvent) : float32 =
+ rumor.Strength * rumorDecayWeight nowTick rumor.Tick
+
+ let private latestRumorFor (receiver: NpcId) (world: World) : RumorEvent option =
+ world.Rumors
+ |> List.filter (fun rumor ->
+ rumor.Receiver = receiver
+ && rumor.Tick <= world.Tick
+ && world.Tick - rumor.Tick <= rumorFreshnessTicks
+ && rumorStrengthAt world.Tick rumor >= rumorMinimumStrength)
+ |> List.sortByDescending (fun rumor ->
+ let (RumorId id) = rumor.Id
+ rumor.Tick, id)
+ |> List.tryHead
+
+ let private nextRumorId (world: World) : RumorId =
+ let maxId =
+ world.Rumors
+ |> List.fold (fun current rumor ->
+ let (RumorId id) = rumor.Id
+ max current id) -1L
+ RumorId(maxId + 1L)
+
+ let private rumorIsDuplicate (request: ChatRequest) (parent: RumorEvent option) (world: World) : bool =
+ let parentId = parent |> Option.map (fun rumor -> rumor.Id)
+ world.Rumors
+ |> List.exists (fun rumor ->
+ rumor.Narrator = request.Narrator
+ && rumor.Receiver = request.Receiver
+ && rumor.Parent = parentId
+ && rumor.Tick <= world.Tick
+ && world.Tick - rumor.Tick <= rumorFreshnessTicks
+ && rumorStrengthAt world.Tick rumor >= rumorMinimumStrength)
+
+ 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)
+ 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 rumorPath (world: World) (target: RumorId) : RumorEvent list =
+ let rec collect (visited: Set<RumorId>) (current: RumorId) (acc: RumorEvent list) =
+ if Set.contains current visited then []
+ else
+ match world.Rumors |> List.tryFind (fun rumor -> rumor.Id = current) with
+ | None -> []
+ | Some rumor ->
+ let nextVisited = Set.add current visited
+ match rumor.Parent with
+ | None -> rumor :: acc
+ | Some parent -> collect nextVisited parent (rumor :: acc)
+ collect Set.empty target []
+
+ let rumorTraceText (world: World) : string =
+ let rumorIdValue (RumorId id) = id
+ let npcIdValue (NpcId id) = id
+ let invariant = System.Globalization.CultureInfo.InvariantCulture
+ let ordered = world.Rumors |> List.sortBy (fun rumor -> rumorIdValue rumor.Id)
+ let rows =
+ ordered
+ |> List.map (fun rumor ->
+ let parent = rumor.Parent |> Option.map rumorIdValue |> Option.map string |> Option.defaultValue "-"
+ let path =
+ rumorPath world rumor.Id
+ |> List.map (fun item -> item.Id |> rumorIdValue |> string)
+ |> String.concat ","
+ let pathText = if path = "" then "-" else path
+ sprintf "rumor id=%d tick=%d origin_tick=%d source=%d narrator=%d receiver=%d parent=%s depth=%d strength=%s path=%s"
+ (rumorIdValue rumor.Id)
+ rumor.Tick
+ rumor.OriginTick
+ (npcIdValue rumor.Source)
+ (npcIdValue rumor.Narrator)
+ (npcIdValue rumor.Receiver)
+ parent
+ rumor.Depth
+ (rumor.Strength.ToString("0.000000", invariant))
+ pathText)
+ let header = sprintf "rumor_trace tick=%d count=%d" world.Tick world.Rumors.Length
+ String.concat "\n" (header :: rows)
+
let private stepNpc (tick: int64) (npcs: Npc[]) (pending: ResizeArray<InteractionEvent>) (npc: Npc) : Npc =
let decayed =
needsClamp
@@ -531,40 +715,6 @@ module Sim =
else
{ npc with Pos = nextPos; Mind = mind1 }
- let private applyChatInit (tick: int64) (a: NpcId) (b: NpcId) (npcs: Npc[]) : Npc[] =
- if a = b then npcs
- else
- let mutable ia = -1
- let mutable ib = -1
- for i in 0 .. npcs.Length - 1 do
- let id = npcs.[i].Id
- if id = a then ia <- i elif id = b then ib <- i
- if ia < 0 || ib < 0 then npcs
- else
- let na = npcs.[ia]
- let nb = npcs.[ib]
- if chatableForChat na && chatableForChat nb && ia <> ib then
- npcs.[ia] <-
- { na with
- Mind =
- { na.Mind with
- Action = Chat
- Target = nb.Pos
- ActionAge = 0L
- EffectDone = false
- Memory = recordMemory tick (Chatted b) na.Mind.Memory } }
- npcs.[ib] <-
- { nb with
- Mind =
- { nb.Mind with
- Action = Chat
- Target = na.Pos
- ActionAge = 0L
- EffectDone = false
- Memory = recordMemory tick (Chatted a) nb.Mind.Memory } }
- npcs
- else npcs
-
let step (ts: TimeStep) (world: World) : World =
let tick = world.Tick + 1L
let maxX = float32 (mapWidthTiles * tilePixels - tilePixels)
@@ -579,21 +729,25 @@ 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
- if queue.Length > 0 then
- for ev in queue do
- match ev.Kind with
- | ChatInit (a, b) -> applyChatInit ev.Tick a b newNpcs |> ignore
- | TradeEvent _ -> ()
- { Tick = tick
- Time = float tick * dtSeconds
- Rng = rngNext
- Avatar =
- { Pos =
- { X = clamp (world.Avatar.Pos.X + dx) 0.0f maxX
- Y = clamp (world.Avatar.Pos.Y + dy) 0.0f maxY } }
- NoHost = world.NoHost
- Npcs = newNpcs
- Events = [] }
+ let baseWorld =
+ { world with
+ Tick = tick
+ Time = float tick * dtSeconds
+ Rng = rngNext
+ Avatar =
+ { Pos =
+ { X = clamp (world.Avatar.Pos.X + dx) 0.0f maxX
+ Y = clamp (world.Avatar.Pos.Y + dy) 0.0f maxY } }
+ Npcs = newNpcs }
+ let mutable nextWorld = baseWorld
+ for ev in queue do
+ match ev.Kind with
+ | ChatInit (a, b) ->
+ match chat { Narrator = a; Receiver = b } nextWorld with
+ | ChatSucceeded (_, updated) -> nextWorld <- updated
+ | ChatRejected (_, _) -> ()
+ | TradeEvent _ -> ()
+ { nextWorld with Events = [] }
// ---- 关系网派生:rel(i,j) = sum(Chatted valence * exp(-ln2*(now-tick)/HALF_LIFE))(真半衰:age=HALF_LIFE 处权重恰 0.5)----