summaryrefslogtreecommitdiff
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
parent23a9235f29e92adb51c3bf1a71f92cd8434e0939 (diff)
downloadliving-village-4f8aca922e6cf47dce95aab79541065d487d5272.tar.gz
feat(m4b): 增加可追踪谣言传播与回放
[变更性质] - 本提交新增 M4b 谣言传播与确定性验收能力,不是单纯缺陷修复。 [新增功能] - 支持带来源、父节点、深度、强度和路径的谣言传播、去重、衰减与过期处理。 - Headless 支持稳定谣言 dump、replay,以及与 batch 模式的互斥校验。 [实现方案] - 通过不可变 World 更新和失败原子返回保持模拟确定性。 - 新增多跳传播、重复转述、过期父节点、非法参与者和稳定 trace 测试。 [影响范围] - 影响 Kernel、Headless 和 Kernel 测试;不包含 M5/M6 或无关配置与文档改动。
-rw-r--r--src/LivingVillage.Headless/Program.fs43
-rw-r--r--src/LivingVillage.Kernel.Tests/DeterminismTests.fs1
-rw-r--r--src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj1
-rw-r--r--src/LivingVillage.Kernel.Tests/RelationTests.fs6
-rw-r--r--src/LivingVillage.Kernel.Tests/RumorTests.fs179
-rw-r--r--src/LivingVillage.Kernel.Tests/TradeTests.fs3
-rw-r--r--src/LivingVillage.Kernel/Sim.fs256
7 files changed, 423 insertions, 66 deletions
diff --git a/src/LivingVillage.Headless/Program.fs b/src/LivingVillage.Headless/Program.fs
index bcf2e41..6ba0f5f 100644
--- a/src/LivingVillage.Headless/Program.fs
+++ b/src/LivingVillage.Headless/Program.fs
@@ -164,6 +164,16 @@ let dumpRelations (world: World) : unit =
printfn "rel_counts %s" (String.concat " " cells)
printfn "rel_total=%d rel_max=%d rel_min=%d" (Array.sum counts) (Array.max counts) (Array.min counts)
+let dumpRumors (world: World) : unit =
+ printfn "%s" (Sim.rumorTraceText world)
+
+let replayRumors (days: int64) (seed: uint64) (npcCount: int) (world: World) : bool =
+ let expected = Sim.rumorTraceText world
+ let replayed = runSimulation false days seed npcCount |> fun stats -> Sim.rumorTraceText stats.World
+ let same = expected = replayed
+ printfn "rumor_replay=%s tick=%d count=%d" (if same then "PASS" else "FAIL") world.Tick world.Rumors.Length
+ same
+
let gini (xs: int64[]) : float =
let n = float xs.Length
let s = float (Array.sum xs)
@@ -266,45 +276,54 @@ let main argv =
(seed: uint64 option)
(npc: int option)
(dump: bool)
+ (rumorTrace: bool)
+ (rumorReplay: bool)
(batch: (int * int64) option)
- : Result<int64 * uint64 * int * bool * (int * int64) option, string> =
+ : Result<int64 * uint64 * int * bool * bool * bool * (int * int64) option, string> =
if i >= argv.Length then
match batch with
- | Some(k, d) -> Ok(defaultArg days 1L, defaultArg seed 42UL, defaultArg npc 30, dump, Some(k, d))
+ | Some(k, d) -> Ok(defaultArg days 1L, defaultArg seed 42UL, defaultArg npc 30, dump, rumorTrace, rumorReplay, Some(k, d))
| None ->
match days, seed, npc with
- | Some d, Some s, Some n -> Ok(d, s, n, dump, None)
+ | Some d, Some s, Some n -> Ok(d, s, n, dump, rumorTrace, rumorReplay, None)
| _ -> Error "missing --days/--seed"
else
match argv.[i] with
| "--days" when i + 1 < argv.Length ->
(match Int64.TryParse argv.[i + 1] with
- | true, d when d > 0L -> parse (i + 2) (Some d) seed npc dump batch
+ | true, d when d > 0L -> parse (i + 2) (Some d) seed npc dump rumorTrace rumorReplay batch
| _ -> Error $"invalid --days '{argv.[i + 1]}'")
| "--seed" when i + 1 < argv.Length ->
(match UInt64.TryParse argv.[i + 1] with
- | true, s -> parse (i + 2) days (Some s) npc dump batch
+ | true, s -> parse (i + 2) days (Some s) npc dump rumorTrace rumorReplay batch
| _ -> Error $"invalid --seed '{argv.[i + 1]}'")
| "--npc" when i + 1 < argv.Length ->
(match Int32.TryParse argv.[i + 1] with
- | true, n when n > 0 && n <= 1000 -> parse (i + 2) days seed (Some n) dump batch
+ | true, n when n > 0 && n <= 1000 -> parse (i + 2) days seed (Some n) dump rumorTrace rumorReplay batch
| _ -> Error $"invalid --npc '{argv.[i + 1]}'")
- | "--dump-relations" -> parse (i + 1) days seed npc true batch
+ | "--dump-relations" -> parse (i + 1) days seed npc true rumorTrace rumorReplay batch
+ | "--dump-rumors" -> parse (i + 1) days seed npc dump true rumorReplay batch
+ | "--replay-rumors" -> parse (i + 1) days seed npc dump rumorTrace true batch
| "--batch" when i + 2 < argv.Length ->
(match Int32.TryParse argv.[i + 1], Int64.TryParse argv.[i + 2] with
- | (true, k), (true, d) when k > 0 && d > 0L -> parse (i + 3) days seed npc dump (Some(k, d))
+ | (true, k), (true, d) when k > 0 && d > 0L -> parse (i + 3) days seed npc dump rumorTrace rumorReplay (Some(k, d))
| _ -> Error $"invalid --batch '{argv.[i + 1]} {argv.[i + 2]}'")
| other -> Error $"unknown argument '{other}'"
- match parse 0 None None (Some 30) false None with
- | Ok(days, seed, npc, dump, batch) ->
+ match parse 0 None None (Some 30) false false false None with
+ | Ok(days, seed, npc, dump, rumorTrace, rumorReplay, batch) ->
match batch with
+ | Some(k, d) when rumorTrace || rumorReplay ->
+ eprintfn "headless: rumor trace/replay cannot be combined with --batch"
+ 2
| Some(k, d) -> runBatch k d
| None ->
let stats = runSimulation true days seed npc
if dump then dumpRelations stats.World
- if stats.NonFinite > 0L || stats.OutOfBounds > 0L then 1 else 0
+ if rumorTrace then dumpRumors stats.World
+ let replayOk = if rumorReplay then replayRumors days seed npc stats.World else true
+ if stats.NonFinite > 0L || stats.OutOfBounds > 0L || not replayOk then 1 else 0
| Error msg ->
eprintfn $"headless: {msg}"
- eprintfn "usage: dotnet run -c Release --project src/LivingVillage.Headless -- --days N --seed S [--npc N] [--dump-relations] | --batch K D"
+ eprintfn "usage: dotnet run -c Release --project src/LivingVillage.Headless -- --days N --seed S [--npc N] [--dump-relations] [--dump-rumors] [--replay-rumors] | --batch K D"
2
diff --git a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs
index c37be67..cc1944f 100644
--- a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs
+++ b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs
@@ -234,6 +234,7 @@ type DeterminismTests () =
| Pay -> 0.2f
| Hungry -> -0.4f
| Chatted _ -> Sim.chatValence
+ | Rumor _ -> 0.1f
| Bought _ -> 0.3f
| Sold _ -> 0.3f
if e.Valence <> expected then
diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
index d805d73..f65200b 100644
--- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
+++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj
@@ -8,6 +8,7 @@
<ItemGroup>
<Compile Include="DeterminismTests.fs" />
<Compile Include="RelationTests.fs" />
+ <Compile Include="RumorTests.fs" />
<Compile Include="TradeTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Kernel.Tests/RelationTests.fs b/src/LivingVillage.Kernel.Tests/RelationTests.fs
index dc433a7..20331ce 100644
--- a/src/LivingVillage.Kernel.Tests/RelationTests.fs
+++ b/src/LivingVillage.Kernel.Tests/RelationTests.fs
@@ -35,7 +35,8 @@ module RelationHarness =
Avatar = { Pos = { X = 0.0f; Y = 0.0f } }
NoHost = { Reserved = 0UL }
Npcs = [| mkNpc 0 mem0; mkNpc 1 mem1 |]
- Events = [] }
+ Events = []
+ Rumors = [] }
let chatted (tick: int64) (partner: int) : MemoryEvent =
{ Tick = tick; Kind = Chatted(NpcId partner); Valence = Sim.chatValence }
@@ -184,7 +185,8 @@ type RelationTests () =
Avatar = { Pos = plaza }
NoHost = { Reserved = 0UL }
Npcs = [| self; nearest; alternate |]
- Events = [] }
+ Events = []
+ Rumors = [] }
let next = Sim.step RelationHarness.zeroInput world
match List.tryHead next.Npcs.[0].Mind.Memory with
| Some entry ->
diff --git a/src/LivingVillage.Kernel.Tests/RumorTests.fs b/src/LivingVillage.Kernel.Tests/RumorTests.fs
new file mode 100644
index 0000000..3c9b267
--- /dev/null
+++ b/src/LivingVillage.Kernel.Tests/RumorTests.fs
@@ -0,0 +1,179 @@
+namespace LivingVillage.Kernel.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+
+module private RumorHarness =
+
+ let private needs =
+ { Hunger = 100.0f
+ Energy = 100.0f
+ Social = 100.0f
+ Money = 50.0f }
+
+ let private personality =
+ { Drive = 0.5f
+ Aggression = 0.0f
+ Extraversion = 0.8f
+ Honesty = 0.5f
+ Greed = 0.5f }
+
+ let private npc id =
+ { Id = NpcId id
+ Pos = { X = float32 (id * 10); Y = 0.0f }
+ Inventory = Map.empty
+ Mind =
+ { Needs = needs
+ Personality = personality
+ Action = Wander
+ Target = actionTarget Wander
+ ActionAge = minActionTicks
+ EffectDone = true
+ HungerFlagged = false
+ Memory = [] } }
+
+ let world tick count =
+ { Tick = tick
+ Time = float tick * dtSeconds
+ Rng = Rng.ofSeed 11UL
+ Avatar = { Pos = { X = 0.0f; Y = 0.0f } }
+ NoHost = { Reserved = 0UL }
+ Npcs = Array.init count npc
+ Events = []
+ Rumors = [] }
+
+ let readyForChat (world: World) : World =
+ { world with
+ Npcs =
+ world.Npcs
+ |> Array.map (fun npc ->
+ { npc with
+ Mind =
+ { npc.Mind with
+ Action = Wander
+ Target = actionTarget Wander
+ ActionAge = minActionTicks
+ EffectDone = true } }) }
+
+ let chat narrator receiver world =
+ match Sim.chat { Narrator = NpcId narrator; Receiver = NpcId receiver } world with
+ | ChatSucceeded (_, next) -> next
+ | ChatRejected (failure, _) -> Assert.Fail($"chat rejected: {failure}"); Unchecked.defaultof<World>
+
+ let rumorId (RumorId id) = id
+
+ let orderedRumors (world: World) =
+ world.Rumors |> List.sortBy (fun rumor -> rumorId rumor.Id)
+
+ let traceText (world: World) =
+ orderedRumors world
+ |> List.map (fun rumor ->
+ let parent = rumor.Parent |> Option.map rumorId |> Option.map string |> Option.defaultValue "-"
+ sprintf "%d|%d|%d|%d|%d|%s|%d"
+ (rumorId rumor.Id)
+ rumor.Tick
+ (match rumor.Source with NpcId id -> id)
+ (match rumor.Narrator with NpcId id -> id)
+ (match rumor.Receiver with NpcId id -> id)
+ parent
+ rumor.Depth)
+ |> String.concat "\n"
+
+[<TestClass>]
+type RumorTests () =
+
+ [<TestMethod>]
+ member _.IdleChatsCreateThreeHopTraceableRumorPathWithinTwoDays () =
+ let initial = RumorHarness.world 0L 4
+ let first = RumorHarness.chat 0 1 initial
+ let second = RumorHarness.chat 1 2 (RumorHarness.readyForChat { first with Tick = ticksPerDay })
+ let third = RumorHarness.chat 2 3 (RumorHarness.readyForChat { second with Tick = 2L * ticksPerDay })
+ let ordered = RumorHarness.orderedRumors third
+
+ Assert.AreEqual<int>(3, third.Rumors.Length)
+ Assert.AreEqual<int>(3, ordered.Length)
+ let root = ordered.[0]
+ let retelling = ordered.[1]
+ let final = ordered.[2]
+ Assert.AreEqual<NpcId>(NpcId 0, root.Source)
+ Assert.AreEqual<NpcId>(NpcId 0, root.Narrator)
+ Assert.AreEqual<NpcId>(NpcId 1, root.Receiver)
+ Assert.IsTrue(root.Parent.IsNone)
+ Assert.AreEqual<int>(0, root.Depth)
+ Assert.AreEqual<RumorId>(root.Id, retelling.Parent |> Option.get)
+ Assert.AreEqual<NpcId>(root.Source, retelling.Source)
+ Assert.AreEqual<NpcId>(NpcId 1, retelling.Narrator)
+ Assert.AreEqual<NpcId>(NpcId 2, retelling.Receiver)
+ Assert.AreEqual<int>(1, retelling.Depth)
+ Assert.AreEqual<RumorId>(retelling.Id, final.Parent |> Option.get)
+ Assert.AreEqual<NpcId>(NpcId 2, final.Narrator)
+ Assert.AreEqual<NpcId>(NpcId 3, final.Receiver)
+ Assert.AreEqual<int>(2, final.Depth)
+
+ let path = Sim.rumorPath third final.Id
+ CollectionAssert.AreEqual([| root; retelling; final |], List.toArray path)
+
+ [<TestMethod>]
+ member _.DuplicateRetellingDoesNotCreateAnotherRumorNode () =
+ let first = RumorHarness.chat 0 1 (RumorHarness.world 0L 3)
+ let second = RumorHarness.chat 1 2 (RumorHarness.readyForChat first)
+ let duplicate = RumorHarness.chat 1 2 (RumorHarness.readyForChat second)
+
+ Assert.AreEqual<int>(2, second.Rumors.Length)
+ Assert.AreEqual<int>(2, duplicate.Rumors.Length)
+ Assert.AreEqual<string>(RumorHarness.traceText second, RumorHarness.traceText duplicate)
+
+ [<TestMethod>]
+ member _.StaleRumorIsNotUsedAsParentForAReTelling () =
+ let first = RumorHarness.chat 0 1 (RumorHarness.world 0L 3)
+ let stale =
+ first
+ |> RumorHarness.readyForChat
+ |> fun world -> { world with Tick = rumorFreshnessTicks + 1L }
+ let next = RumorHarness.chat 1 2 stale
+ let latest = RumorHarness.orderedRumors next |> List.last
+
+ Assert.IsTrue(latest.Parent.IsNone, "stale memory must not be reused as a parent")
+ Assert.IsTrue(latest.Source = NpcId 1, "a stale rumor starts a new local source")
+ Assert.IsTrue(Sim.rumorStrengthAt next.Tick (RumorHarness.orderedRumors next |> List.head) < rumorMinimumStrength)
+
+ [<TestMethod>]
+ member _.InvalidParticipantsLeaveWorldUnchanged () =
+ let before = RumorHarness.world 10L 2
+ let requests =
+ [ { Narrator = NpcId 9; Receiver = NpcId 1 }
+ { Narrator = NpcId 0; Receiver = NpcId 9 }
+ { Narrator = NpcId 0; Receiver = NpcId 0 } ]
+
+ for request in requests do
+ match Sim.chat request before with
+ | ChatRejected (_, unchanged) -> Assert.AreEqual<World>(before, unchanged)
+ | ChatSucceeded _ -> Assert.Fail($"expected invalid chat to be rejected: {request}")
+
+ [<TestMethod>]
+ member _.SameSeedAndChatOperationsProduceIdenticalRumorTrace () =
+ let run seed =
+ let initial = { RumorHarness.world 0L 4 with Rng = Rng.ofSeed seed }
+ let first = RumorHarness.chat 0 1 initial
+ let second = RumorHarness.chat 1 2 (RumorHarness.readyForChat { first with Tick = ticksPerDay })
+ let third = RumorHarness.chat 2 3 (RumorHarness.readyForChat { second with Tick = 2L * ticksPerDay })
+ RumorHarness.traceText third
+
+ Assert.AreEqual<string>(run 4242UL, run 4242UL)
+
+ [<TestMethod>]
+ member _.RumorTraceTextContainsStableMetadataAndReplayablePaths () =
+ let initial = RumorHarness.world 0L 4
+ let first = RumorHarness.chat 0 1 initial
+ let second = RumorHarness.chat 1 2 (RumorHarness.readyForChat { first with Tick = ticksPerDay })
+ let third = RumorHarness.chat 2 3 (RumorHarness.readyForChat { second with Tick = 2L * ticksPerDay })
+
+ let expected =
+ String.concat "\n"
+ [ sprintf "rumor_trace tick=%d count=3" (2L * ticksPerDay)
+ "rumor id=0 tick=0 origin_tick=0 source=0 narrator=0 receiver=1 parent=- depth=0 strength=1.000000 path=0"
+ sprintf "rumor id=1 tick=%d origin_tick=0 source=0 narrator=1 receiver=2 parent=0 depth=1 strength=0.500000 path=0,1" ticksPerDay
+ sprintf "rumor id=2 tick=%d origin_tick=0 source=0 narrator=2 receiver=3 parent=1 depth=2 strength=0.250000 path=0,1,2" (2L * ticksPerDay) ]
+
+ Assert.AreEqual<string>(expected, Sim.rumorTraceText third)
diff --git a/src/LivingVillage.Kernel.Tests/TradeTests.fs b/src/LivingVillage.Kernel.Tests/TradeTests.fs
index c7c8ef4..5a0e724 100644
--- a/src/LivingVillage.Kernel.Tests/TradeTests.fs
+++ b/src/LivingVillage.Kernel.Tests/TradeTests.fs
@@ -44,7 +44,8 @@ module private TradeHarness =
Npcs =
[| npc 0 buyerMoney buyerHunger buyerFood [] None
npc 1 sellerMoney sellerHunger sellerFood sellerMemory None |]
- Events = [] }
+ Events = []
+ Rumors = [] }
let request quantity =
{ Buyer = NpcId 0
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)----