From c1242ee391a25d5e5664befb5efe84b3e471ffd2 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Sat, 19 Sep 2026 11:07:07 +0800 Subject: feat: M3b 关系网络派生 — Sim.relationMatrix 双向累加+时间衰减(halfLife 86400tick, |r|>0.5 阈值) + headless --dump-relations 与 --batch K D 验收(chats max/avg、relcnt 差异、无NaN) + Desktop L 键关系视图(暂停+快照, 绿/红线粗细∝|r|, cyan NPC 点) + LV_STEPS_PER_FRAME 调试快进 + 衰减/矩阵/30npc 对称测试(19/19) + xvfb 截图验证 PASS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/LivingVillage.Desktop/Game.fs | 104 ++++++++++--- src/LivingVillage.Headless/Program.fs | 166 +++++++++++++++++---- .../LivingVillage.Kernel.Tests.fsproj | 1 + src/LivingVillage.Kernel.Tests/RelationTests.fs | 122 +++++++++++++++ src/LivingVillage.Kernel/Sim.fs | 39 +++++ 5 files changed, 382 insertions(+), 50 deletions(-) create mode 100644 src/LivingVillage.Kernel.Tests/RelationTests.fs diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 2e28760..62fccb9 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -79,6 +79,13 @@ type LivingVillageGame() as this = let autoplay = Environment.GetEnvironmentVariable("LV_AUTOPLAY") = "1" let titleSuffix = if autoplay then " [AUTOPLAY]" else "" let modeName = if autoplay then "autoplay" else "keyboard" + let stepsPerFrame = + match Environment.GetEnvironmentVariable("LV_STEPS_PER_FRAME") with + | null | "" -> 1 + | s -> + match Int32.TryParse s with + | true, n when n > 0 -> n + | _ -> 1 let graphics = new GraphicsDeviceManager(this) let mutable spriteBatch = Unchecked.defaultof let mutable atlas = Unchecked.defaultof @@ -88,6 +95,9 @@ type LivingVillageGame() as this = let mutable camera = Vector2.Zero let mutable fpsFrames = 0 let mutable fpsSeconds = 0.0 + let mutable prevKb = Unchecked.defaultof + let mutable relationView = false + let mutable relationSnapshot: float32[,] option = None do graphics.PreferredBackBufferWidth <- 1280 @@ -108,6 +118,10 @@ type LivingVillageGame() as this = let y = MathHelper.Clamp(world.Avatar.Pos.Y + half - vh / 2.0f, 0.0f, max 0.0f (worldH - vh)) camera <- Vector2(x, y) + override this.Initialize() = + base.Initialize() + prevKb <- Keyboard.GetState() + override _.LoadContent() = spriteBatch <- new SpriteBatch(this.GraphicsDevice) atlas <- Atlas.build this.GraphicsDevice @@ -117,23 +131,32 @@ type LivingVillageGame() as this = override this.Update(gameTime: GameTime) = let kb = Keyboard.GetState() if kb.IsKeyDown(Keys.Escape) then this.Exit() - let input = - if autoplay then - let t = float32 world.Tick - { MoveX = MathF.Sin(t / 60.0f) * 0.8f - MoveY = MathF.Cos(t / 90.0f) * 0.6f } - else - { MoveX = - (if kb.IsKeyDown(Keys.D) then 1.0f - elif kb.IsKeyDown(Keys.A) then -1.0f - else 0.0f) - MoveY = - (if kb.IsKeyDown(Keys.S) then 1.0f - elif kb.IsKeyDown(Keys.W) then -1.0f - else 0.0f) } - let ts = { Input = input } - world <- Sim.step ts world - this.CenterCamera() + let lPressed = kb.IsKeyDown(Keys.L) && not (prevKb.IsKeyDown(Keys.L)) + prevKb <- kb + if lPressed then + relationView <- not relationView + if relationView then relationSnapshot <- Some(Sim.relationMatrix world) + let state = if relationView then "on" else "off" + printfn $"relation view={state} tick={world.Tick}" + if not relationView then + let input = + if autoplay then + let t = float32 world.Tick + { MoveX = MathF.Sin(t / 60.0f) * 0.8f + MoveY = MathF.Cos(t / 90.0f) * 0.6f } + else + { MoveX = + (if kb.IsKeyDown(Keys.D) then 1.0f + elif kb.IsKeyDown(Keys.A) then -1.0f + else 0.0f) + MoveY = + (if kb.IsKeyDown(Keys.S) then 1.0f + elif kb.IsKeyDown(Keys.W) then -1.0f + else 0.0f) } + let ts = { Input = input } + for _ in 1 .. stepsPerFrame do + world <- Sim.step ts world + this.CenterCamera() fpsFrames <- fpsFrames + 1 fpsSeconds <- fpsSeconds + gameTime.ElapsedGameTime.TotalSeconds if fpsSeconds >= 1.0 then @@ -144,10 +167,55 @@ type LivingVillageGame() as this = if world.Npcs.Length > 0 then NpcView.actionName world.Npcs.[0].Mind.Action else "none" - this.Window.Title <- $"Living Village M3a{titleSuffix} | fps {fps:F1} | tick {world.Tick} | npc {npcAction}" + let viewTag = if relationView then " | [RELATIONS]" else "" + this.Window.Title <- $"Living Village M3b{titleSuffix} | fps {fps:F1} | tick {world.Tick} | npc {npcAction}{viewTag}" printfn $"fps={fps:F1} tick={world.Tick} pos=({world.Avatar.Pos.X:F0},{world.Avatar.Pos.Y:F0})" + member private this.DrawLine (a: Vector2) (b: Vector2) (thickness: float32) (color: Color) = + let d = b - a + let len = d.Length() + if len > 1.0f then + let angle = MathF.Atan2(d.Y, d.X) + spriteBatch.Draw(pixel, a, System.Nullable(), color, angle, Vector2.Zero, Vector2(len, thickness), SpriteEffects.None, 0.0f) + + member private this.DrawRelationView() = + this.GraphicsDevice.Clear(Color(16, 20, 26)) + let viewport = this.GraphicsDevice.Viewport + let vw = float32 viewport.Width + let vh = float32 viewport.Height + let mapW = float32 (Sim.mapWidthTiles * Sim.tilePixels) + let mapH = float32 (Sim.mapHeightTiles * Sim.tilePixels) + let margin = 48.0f + let scale = min ((vw - 2.0f * margin) / mapW) ((vh - 2.0f * margin) / mapH) + let ox = (vw - mapW * scale) / 2.0f + let oy = (vh - mapH * scale) / 2.0f + let toScreen (p: Vec2) : Vector2 = Vector2(ox + p.X * scale, oy + p.Y * scale) + spriteBatch.Begin() + match relationSnapshot with + | Some m -> + let n = Array2D.length1 m + for i in 0 .. n - 1 do + for j in i + 1 .. n - 1 do + let r = m.[i, j] + if abs r > Sim.relationThreshold then + let a = toScreen world.Npcs.[i].Pos + let b = toScreen world.Npcs.[j].Pos + let color = if r > 0.0f then Color.LimeGreen else Color.IndianRed + let thickness = Sim.clamp (1.5f + 3.0f * abs r) 1.5f 12.0f + this.DrawLine a b thickness color + for npc in world.Npcs do + let p = toScreen npc.Pos + spriteBatch.Draw(pixel, Rectangle(int p.X - 5, int p.Y - 5, 10, 10), Color.Cyan) + | None -> () + spriteBatch.End() + override this.Draw(gameTime: GameTime) = + if relationView then + this.DrawRelationView() + else + this.DrawWorldView() + + member private this.DrawWorldView() = this.GraphicsDevice.Clear(Color.CornflowerBlue) let viewport = this.GraphicsDevice.Viewport let camX = int camera.X diff --git a/src/LivingVillage.Headless/Program.fs b/src/LivingVillage.Headless/Program.fs index 28fe121..e8c7a88 100644 --- a/src/LivingVillage.Headless/Program.fs +++ b/src/LivingVillage.Headless/Program.fs @@ -64,9 +64,16 @@ let actionDistribution (npcs: Npc[]) : int * int * int * int * int = | Chat -> (e, s, w, k, c + 1)) (0, 0, 0, 0, 0) counts -let runSimulation (days: int64) (seed: uint64) (npcCount: int) : int = +type RunStats = + { ChatsPerNpc: int64[] + NonFinite: int64 + OutOfBounds: int64 + World: World } + +let runSimulation (verbose: bool) (days: int64) (seed: uint64) (npcCount: int) : RunStats = let totalTicks = days * ticksPerDay - printfn "living-village headless days=%d seed=%d npcs=%d ticks=%d tickrate=%d" days seed npcCount totalTicks ticksPerSecond + if verbose then + printfn "living-village headless days=%d seed=%d npcs=%d ticks=%d tickrate=%d" days seed npcCount totalTicks ticksPerSecond let mutable world = Sim.initialWorldN seed npcCount let mutable driver = { Rng = Rng.ofSeed seed; Until = segmentTicks; Input = zeroInput } let maxX = float32 (Sim.mapWidthTiles * Sim.tilePixels - Sim.tilePixels) @@ -113,59 +120,154 @@ let runSimulation (days: int64) (seed: uint64) (npcCount: int) : int = if Single.IsNaN p.X || Single.IsNaN p.Y || Single.IsInfinity p.X || Single.IsInfinity p.Y then nonFinite <- nonFinite + 1L if p.X < 0.0f || p.X > maxX || p.Y < 0.0f || p.Y > maxY then outOfBounds <- outOfBounds + 1L - if t % summaryInterval = 0L then + if verbose && t % summaryInterval = 0L then let eat, sleep, wander, work, chat = actionDistribution world.Npcs let chatsTotal = Array.sum npcTotal let day = float t / float ticksPerDay printfn "summary tick=%d day=%.4f eat=%d sleep=%d wander=%d work=%d chat=%d chats_window=%d" t day eat sleep wander work chat (chatsTotal - chatsWindowStart) chatsWindowStart <- chatsTotal - let segments = switchCount + 1L - let avgActionTicks = float (switchDurSum + (totalTicks - lastStart)) / float segments - let nightPct = if nightTicks > 0L then 100.0 * float sleepNight / float nightTicks else 0.0 - let dayPct = if dayTicks > 0L then 100.0 * float sleepDay / float dayTicks else 0.0 - let npc0 = world.Npcs.[0] - let memCount = List.length npc0.Mind.Memory - let lastValence = - match List.tryHead npc0.Mind.Memory with - | Some e -> e.Valence - | None -> 0.0f - printfn "npc=%A action_switches=%d avg_action_ticks=%.1f sleep_night_ticks=%d sleep_day_ticks=%d sleep_night_pct=%.2f sleep_day_pct=%.2f mem_count=%d last_valence=%.2f" - npc0.Id switchCount avgActionTicks sleepNight sleepDay nightPct dayPct memCount lastValence - let chatsTotal = Array.sum npcTotal - let minChats = if npcCount > 0 then Array.min npcTotal else 0L - let maxChats = if npcCount > 0 then Array.max npcTotal else 0L - let avgChats = if npcCount > 0 then float chatsTotal / float npcCount else 0.0 - printfn "chats_total=%d chats_min=%d chats_max=%d chats_avg=%.2f" chatsTotal minChats maxChats avgChats - printfn "done tick=%d non-finite=%d out-of-bounds=%d" world.Tick nonFinite outOfBounds - if nonFinite > 0L || outOfBounds > 0L then 1 else 0 + if verbose then + let segments = switchCount + 1L + let avgActionTicks = float (switchDurSum + (totalTicks - lastStart)) / float segments + let nightPct = if nightTicks > 0L then 100.0 * float sleepNight / float nightTicks else 0.0 + let dayPct = if dayTicks > 0L then 100.0 * float sleepDay / float dayTicks else 0.0 + let npc0 = world.Npcs.[0] + let memCount = List.length npc0.Mind.Memory + let lastValence = + match List.tryHead npc0.Mind.Memory with + | Some e -> e.Valence + | None -> 0.0f + printfn "npc=%A action_switches=%d avg_action_ticks=%.1f sleep_night_ticks=%d sleep_day_ticks=%d sleep_night_pct=%.2f sleep_day_pct=%.2f mem_count=%d last_valence=%.2f" + npc0.Id switchCount avgActionTicks sleepNight sleepDay nightPct dayPct memCount lastValence + let chatsTotal = Array.sum npcTotal + let minChats = if npcCount > 0 then Array.min npcTotal else 0L + let maxChats = if npcCount > 0 then Array.max npcTotal else 0L + let avgChats = if npcCount > 0 then float chatsTotal / float npcCount else 0.0 + printfn "chats_total=%d chats_min=%d chats_max=%d chats_avg=%.2f" chatsTotal minChats maxChats avgChats + printfn "done tick=%d non-finite=%d out-of-bounds=%d" world.Tick nonFinite outOfBounds + { ChatsPerNpc = npcTotal + NonFinite = nonFinite + OutOfBounds = outOfBounds + World = world } + +let dumpRelations (world: World) : unit = + let m = Sim.relationMatrix world + let n = Array2D.length1 m + let ci = System.Globalization.CultureInfo.InvariantCulture + printfn "relations tick=%d npcs=%d half_life_ticks=%d threshold=%.2f" world.Tick n Sim.relationHalfLifeTicks Sim.relationThreshold + for i in 0 .. n - 1 do + let row = Array.init n (fun j -> m.[i, j].ToString("0.000", ci)) + printfn "rel[%d] %s" i (String.concat " " row) + let counts = Sim.relationCounts m + let cells = Array.mapi (fun i c -> $"{i}:{c}") counts + 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 gini (xs: int64[]) : float = + let n = float xs.Length + let s = float (Array.sum xs) + if n <= 1.0 || s <= 0.0 then 0.0 + else + let mutable num = 0.0 + for xi in xs do + for xj in xs do + num <- num + abs (float xi - float xj) + num / (2.0 * n * n * (s / n)) + +let runBatch (worlds: int) (days: int64) : int = + printfn "batch worlds=%d days=%d half_life_ticks=%d rel_threshold=%.2f" worlds days Sim.relationHalfLifeTicks Sim.relationThreshold + printfn "checks: (a) chats max/avg > 2.0 (b) rel_counts max > 2x min (c) no NaN" + let mutable passed = 0 + let mutable failed = 0 + let ratios = ResizeArray () + let ginis = ResizeArray () + let sw = System.Diagnostics.Stopwatch.StartNew() + for k in 0 .. worlds - 1 do + let seed = 42UL + uint64 k + let stats = runSimulation false days seed Sim.npcCount + let m = Sim.relationMatrix stats.World + let n = Array2D.length1 m + let mutable hasNonFinite = stats.NonFinite > 0L + for i in 0 .. n - 1 do + for j in 0 .. n - 1 do + if Single.IsNaN m.[i, j] || Single.IsInfinity m.[i, j] then hasNonFinite <- true + let chats = stats.ChatsPerNpc + let chatsTotal = Array.sum chats + let chatsAvg = float chatsTotal / float chats.Length + let chatsMax = if chats.Length > 0 then Array.max chats else 0L + let ratio = if chatsAvg > 0.0 then float chatsMax / chatsAvg else 0.0 + let g = gini chats + let counts = Sim.relationCounts m + let cntMax = if counts.Length > 0 then Array.max counts else 0 + let cntMin = if counts.Length > 0 then Array.min counts else 0 + let checkA = ratio > 2.0 + let checkB = float cntMax > 2.0 * float cntMin + let checkC = not hasNonFinite + let ok = checkA && checkB && checkC + if ok then passed <- passed + 1 else failed <- failed + 1 + ratios.Add ratio + ginis.Add g + printfn "world=%d seed=%d chats_max=%d chats_avg=%.2f ratio=%.3f gini=%.3f relcnt_max=%d relcnt_min=%d nonfinite=%d check_a=%s check_b=%s check_c=%s %s" + k seed chatsMax chatsAvg ratio g cntMax cntMin stats.NonFinite + (if checkA then "PASS" else "FAIL") + (if checkB then "PASS" else "FAIL") + (if checkC then "PASS" else "FAIL") + (if ok then "OK" else "BAD") + sw.Stop() + let allPass = failed = 0 + printfn "batch_summary worlds=%d passed=%d failed=%d ratio_min=%.3f ratio_max=%.3f gini_min=%.3f gini_max=%.3f elapsed_s=%.1f" + worlds passed failed (Seq.min ratios) (Seq.max ratios) (Seq.min ginis) (Seq.max ginis) sw.Elapsed.TotalSeconds + printfn "M3_ACCEPTANCE=%s" (if allPass then "PASS" else "FAIL") + if allPass then 0 else 1 [] let main argv = - let rec parse (i: int) (days: int64 option) (seed: uint64 option) (npc: int option) : Result = + let rec parse + (i: int) + (days: int64 option) + (seed: uint64 option) + (npc: int option) + (dump: bool) + (batch: (int * int64) option) + : Result = if i >= argv.Length then - match days, seed, npc with - | Some d, Some s, Some n -> Ok(d, s, n) - | _ -> Error "missing --days/--seed" + match batch with + | Some(k, d) -> Ok(defaultArg days 1L, defaultArg seed 42UL, defaultArg npc 30, dump, Some(k, d)) + | None -> + match days, seed, npc with + | Some d, Some s, Some n -> Ok(d, s, n, dump, 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 + | true, d when d > 0L -> parse (i + 2) (Some d) seed npc dump 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 + | true, s -> parse (i + 2) days (Some s) npc dump 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) + | true, n when n > 0 && n <= 1000 -> parse (i + 2) days seed (Some n) dump batch | _ -> Error $"invalid --npc '{argv.[i + 1]}'") + | "--dump-relations" -> parse (i + 1) days seed npc 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)) + | _ -> Error $"invalid --batch '{argv.[i + 1]} {argv.[i + 2]}'") | other -> Error $"unknown argument '{other}'" - match parse 0 None None (Some 30) with - | Ok(days, seed, npc) -> runSimulation days seed npc + match parse 0 None None (Some 30) false None with + | Ok(days, seed, npc, dump, batch) -> + match batch with + | 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 | Error msg -> eprintfn $"headless: {msg}" - eprintfn "usage: dotnet run -c Release --project src/LivingVillage.Headless -- --days N --seed S [--npc N]" + eprintfn "usage: dotnet run -c Release --project src/LivingVillage.Headless -- --days N --seed S [--npc N] [--dump-relations] | --batch K D" 2 diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj index d1d3c19..65e50db 100644 --- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj +++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj @@ -7,6 +7,7 @@ + diff --git a/src/LivingVillage.Kernel.Tests/RelationTests.fs b/src/LivingVillage.Kernel.Tests/RelationTests.fs new file mode 100644 index 0000000..f1d9673 --- /dev/null +++ b/src/LivingVillage.Kernel.Tests/RelationTests.fs @@ -0,0 +1,122 @@ +namespace LivingVillage.Kernel.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim + +module RelationHarness = + + let zeroInput = { Input = { MoveX = 0.0f; MoveY = 0.0f } } + + let runN (seed: uint64) (count: int) (ticks: int64) : World = + let mutable w = Sim.initialWorldN seed count + for _ in 1L .. ticks do + w <- Sim.step zeroInput w + w + + let handWorld (tick: int64) (mem0: MemoryEvent list) (mem1: MemoryEvent list) : World = + let needs = { Hunger = 100.0f; Energy = 100.0f; Social = 100.0f; Money = 50.0f } + let mkNpc id mem = + { Id = NpcId id + Pos = { X = 0.0f; Y = 0.0f } + Mind = + { Needs = needs + Personality = personalityOfSeed 1UL + Action = Wander + Target = actionTarget Wander + ActionAge = 0L + EffectDone = false + HungerFlagged = false + Memory = mem } } + { Tick = tick + Time = 0.0 + Rng = Rng.ofSeed 1UL + Avatar = { Pos = { X = 0.0f; Y = 0.0f } } + NoHost = { Reserved = 0UL } + Npcs = [| mkNpc 0 mem0; mkNpc 1 mem1 |] + Events = [] } + + let chatted (tick: int64) (partner: int) : MemoryEvent = + { Tick = tick; Kind = Chatted(NpcId partner); Valence = Sim.chatValence } + + let matricesEqual (a: float32[,]) (b: float32[,]) : bool = + Array2D.length1 a = Array2D.length1 b + && Array2D.length2 a = Array2D.length2 b + && (let mutable eq = true + for i in 0 .. Array2D.length1 a - 1 do + for j in 0 .. Array2D.length2 a - 1 do + if a.[i, j] <> b.[i, j] then eq <- false + eq) + +[] +type RelationTests () = + + [] + member _.RelationMatrixSameSeedIsByteIdentical () = + let wa = RelationHarness.runN 42UL 30 600000L + let wb = RelationHarness.runN 42UL 30 600000L + let ma = Sim.relationMatrix wa + let mb = Sim.relationMatrix wb + if wa.Tick <> wb.Tick then Assert.Fail("ticks must match") + Assert.IsTrue(RelationHarness.matricesEqual ma mb, "same seed must yield byte-identical relation matrix") + + [] + member _.RelationDecayWeightNewExceedsOld () = + let now = 10L * Sim.relationHalfLifeTicks + let fresh = Sim.relationDecayWeight now now + let old = Sim.relationDecayWeight now (now - 2L * Sim.relationHalfLifeTicks) + let older = Sim.relationDecayWeight now (now - 4L * Sim.relationHalfLifeTicks) + if fresh <> 1.0f then Assert.Fail("weight of an event at now must be 1") + if old >= fresh then Assert.Fail("older valence must weigh less than newer valence") + if older >= old then Assert.Fail("weight must strictly decrease with age") + + [] + member _.RelationMatrixMatchesHandComputedDecay () = + // 单次闲聊记忆(tick=0, valence=0.3),now = 1 个半衰期 → 双向累加 2 * 0.3 * e^-1 + let w = RelationHarness.handWorld Sim.relationHalfLifeTicks + [ RelationHarness.chatted 0L 1 ] [ RelationHarness.chatted 0L 0 ] + let m = Sim.relationMatrix w + let expected = 2.0f * Sim.chatValence * float32 (System.Math.Exp(-1.0)) + if abs (m.[0, 1] - expected) > 1e-6f then Assert.Fail($"m[0,1]={m.[0, 1]} expected={expected}") + if abs (m.[1, 0] - expected) > 1e-6f then Assert.Fail($"m[1,0]={m.[1, 0]} expected={expected}") + if m.[0, 0] <> 0.0f then Assert.Fail("diagonal must be zero") + if m.[1, 1] <> 0.0f then Assert.Fail("diagonal must be zero") + + [] + member _.RelationMatrixPrefersRecentChats () = + let now = 4L * Sim.relationHalfLifeTicks + let freshWorld = + RelationHarness.handWorld now + [ RelationHarness.chatted now 1 ] [ RelationHarness.chatted now 0 ] + let staleWorld = + RelationHarness.handWorld now + [ RelationHarness.chatted (now - 4L * Sim.relationHalfLifeTicks) 1 ] + [ RelationHarness.chatted (now - 4L * Sim.relationHalfLifeTicks) 0 ] + let mf = Sim.relationMatrix freshWorld + let ms = Sim.relationMatrix staleWorld + if mf.[0, 1] <= ms.[0, 1] then Assert.Fail("recent chat memory must outweigh stale chat memory") + if ms.[0, 1] >= Sim.relationThreshold then Assert.Fail("stale single chat must stay below relation threshold") + + [] + member _.RelationCountsRespectThreshold () = + let now = 4L * Sim.relationHalfLifeTicks + // 双向各 1 次新闲聊 → 矩阵 2*0.3 = 0.6 > 0.5 → 有关系 + let w = RelationHarness.handWorld now + [ RelationHarness.chatted now 1 ] [ RelationHarness.chatted now 0 ] + let m = Sim.relationMatrix w + let counts = Sim.relationCounts m + if counts.[0] <> 1 then Assert.Fail($"npc0 relation count {counts.[0]}, expected 1") + if counts.[1] <> 1 then Assert.Fail($"npc1 relation count {counts.[1]}, expected 1") + + [] + member _.RelationMatrixFromSimulationIsSymmetricNoNaN () = + let w = RelationHarness.runN 42UL 30 600000L + let m = Sim.relationMatrix w + let n = Array2D.length1 m + if n <> 30 then Assert.Fail($"expected 30 npcs, got {n}") + for i in 0 .. n - 1 do + if m.[i, i] <> 0.0f then Assert.Fail($"diagonal must be zero at {i}") + for j in 0 .. n - 1 do + if m.[i, j] <> m.[j, i] then Assert.Fail($"matrix must be symmetric at ({i},{j})") + if System.Single.IsNaN m.[i, j] then Assert.Fail($"NaN at ({i},{j})") + if System.Single.IsInfinity m.[i, j] then Assert.Fail($"infinite at ({i},{j})") diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs index 62ce1e1..ebc435e 100644 --- a/src/LivingVillage.Kernel/Sim.fs +++ b/src/LivingVillage.Kernel/Sim.fs @@ -119,6 +119,10 @@ module Sim = let chatSocialRestore = 40.0f let chatValence = 0.3f + // 关系网派生(纯函数;仅在 dump/视图打开时调用,不进 step 热路径) + let relationHalfLifeTicks = 86400L // HALF_LIFE = 1 天(86400 tick) + let relationThreshold = 0.5f // |rel| > 0.5 视为有关系 + let clamp (v: float32) (lo: float32) (hi: float32) : float32 = if v < lo then lo elif v > hi then hi else v let hungerDecayPerTick = 0.0012f @@ -429,3 +433,38 @@ module Sim = NoHost = world.NoHost Npcs = newNpcs Events = [] } + + // ---- 关系网派生:rel(i,j) = sum(Chatted valence * exp(-(now-tick)/HALF_LIFE)) ---- + + let relationDecayWeight (nowTick: int64) (eventTick: int64) : float32 = + let age = float (nowTick - eventTick) + float32 (exp (-age / float relationHalfLifeTicks)) + + // 30x30 对称矩阵(对角线 0):双向累加双方 Chatted 记忆的衰减 valence + let relationMatrix (world: World) : float32[,] = + let n = world.Npcs.Length + let m = Array2D.zeroCreate n n + for npc in world.Npcs do + let i = + match npc.Id with + | NpcId i -> i + for e in npc.Mind.Memory do + match e.Kind with + | Chatted partner when e.Tick <= world.Tick -> + let j = + match partner with + | NpcId j -> j + if i <> j && i < n && j < n then + let w = e.Valence * relationDecayWeight world.Tick e.Tick + m.[i, j] <- m.[i, j] + w + m.[j, i] <- m.[j, i] + w + | _ -> () + m + + let relationCounts (m: float32[,]) : int[] = + let n = Array2D.length1 m + Array.init n (fun i -> + let mutable c = 0 + for j in 0 .. n - 1 do + if i <> j && abs m.[i, j] > relationThreshold then c <- c + 1 + c) -- cgit v1.2.3