diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-20 12:18:38 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-20 12:18:38 +0800 |
| commit | 4f8aca922e6cf47dce95aab79541065d487d5272 (patch) | |
| tree | 3d0e259440912ccb7ab62b910c576e928326fa96 /src/LivingVillage.Kernel/Sim.fs | |
| parent | 23a9235f29e92adb51c3bf1a71f92cd8434e0939 (diff) | |
| download | living-village-4f8aca922e6cf47dce95aab79541065d487d5272.tar.gz | |
feat(m4b): 增加可追踪谣言传播与回放
[变更性质]
- 本提交新增 M4b 谣言传播与确定性验收能力,不是单纯缺陷修复。
[新增功能]
- 支持带来源、父节点、深度、强度和路径的谣言传播、去重、衰减与过期处理。
- Headless 支持稳定谣言 dump、replay,以及与 batch 模式的互斥校验。
[实现方案]
- 通过不可变 World 更新和失败原子返回保持模拟确定性。
- 新增多跳传播、重复转述、过期父节点、非法参与者和稳定 trace 测试。
[影响范围]
- 影响 Kernel、Headless 和 Kernel 测试;不包含 M5/M6 或无关配置与文档改动。
Diffstat (limited to 'src/LivingVillage.Kernel/Sim.fs')
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 256 |
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)---- |
