summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-19 11:07:07 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-19 11:07:07 +0800
commitc1242ee391a25d5e5664befb5efe84b3e471ffd2 (patch)
treeb166b59e0f68bcc0344af147548ed1236fba8fa8
parent4993bc37cb00356b83cb4f8a2e4a92fc2fe77922 (diff)
downloadliving-village-c1242ee391a25d5e5664befb5efe84b3e471ffd2.tar.gz
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
-rw-r--r--src/LivingVillage.Desktop/Game.fs104
-rw-r--r--src/LivingVillage.Headless/Program.fs166
-rw-r--r--src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj1
-rw-r--r--src/LivingVillage.Kernel.Tests/RelationTests.fs122
-rw-r--r--src/LivingVillage.Kernel/Sim.fs39
5 files changed, 382 insertions, 50 deletions
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<SpriteBatch>
let mutable atlas = Unchecked.defaultof<Texture2D>
@@ -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<KeyboardState>
+ 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<float> ()
+ let ginis = ResizeArray<float> ()
+ 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
[<EntryPoint>]
let main argv =
- let rec parse (i: int) (days: int64 option) (seed: uint64 option) (npc: int option) : Result<int64 * uint64 * int, string> =
+ let rec parse
+ (i: int)
+ (days: int64 option)
+ (seed: uint64 option)
+ (npc: int option)
+ (dump: bool)
+ (batch: (int * int64) option)
+ : Result<int64 * uint64 * int * bool * (int * int64) option, string> =
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 @@
<ItemGroup>
<Compile Include="DeterminismTests.fs" />
+ <Compile Include="RelationTests.fs" />
</ItemGroup>
<ItemGroup>
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)
+
+[<TestClass>]
+type RelationTests () =
+
+ [<TestMethod>]
+ 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")
+
+ [<TestMethod>]
+ 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")
+
+ [<TestMethod>]
+ 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")
+
+ [<TestMethod>]
+ 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")
+
+ [<TestMethod>]
+ 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")
+
+ [<TestMethod>]
+ 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)