diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Headless/Program.fs | 43 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/DeterminismTests.fs | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/RelationTests.fs | 6 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/RumorTests.fs | 179 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/TradeTests.fs | 3 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 256 |
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)---- |
