summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel.Tests/RelationTests.fs
blob: dc67e6d4860039e7f91cca3b014853a56cbf605b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
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 _.OneDayEqualsTicksPerDayConstant () =
        // 守卫:1 模拟日 = 24h * 3600s * 60tick/s = 5,184,000 tick
        if Sim.ticksPerDay <> 5_184_000L then Assert.Fail($"ticksPerDay={Sim.ticksPerDay}, expected 5184000")

    [<TestMethod>]
    member _.RelationHalfLifeEqualsOneDay () =
        // HALF_LIFE 必须 = 1 模拟日(修复单位错误:原 86400L 实为 24 模拟分钟)
        if Sim.relationHalfLifeTicks <> Sim.ticksPerDay then
            Assert.Fail($"relationHalfLifeTicks={Sim.relationHalfLifeTicks}, ticksPerDay={Sim.ticksPerDay}")

    [<TestMethod>]
    member _.RelationDecayWeightAtHalfLifeIsHalf () =
        // 真半衰语义:age = halfLife 处权重恰为 0.5(旧式 exp(-age/halfLife) 给出 e^-1≈0.368)
        let now = 2L * Sim.relationHalfLifeTicks
        let w = Sim.relationDecayWeight now (now - Sim.relationHalfLifeTicks)
        if abs (w - 0.5f) > 1e-4f then Assert.Fail($"weight at one half-life = {w}, expected 0.5")

    [<TestMethod>]
    member _.RelationMatrixMatchesHandComputedDecay () =
        // 单次闲聊记忆(tick=0, valence=0.3),now = 1 个半衰期 → 真半衰权重恰 0.5 → 双向累加 2 * 0.3 * 0.5
        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 * 0.5f
        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})")

    [<TestMethod>]
    member _.ChatMemorySurvivesRoutineMemoryChurn () =
        let initial = [ RelationHarness.chatted 1L 1 ]
        let full =
            [ 1 .. Sim.memoryCapacity - 1 ]
            |> List.fold (fun mem i -> Sim.recordMemory (int64 (i + 1)) Meal mem) initial
        let retained = Sim.recordMemory (int64 (Sim.memoryCapacity + 1)) Meal full
        if retained.Length > Sim.memoryCapacity then Assert.Fail("memory exceeds capacity")
        if not (retained |> List.exists (fun e -> match e.Kind with Chatted _ -> true | _ -> false)) then
            Assert.Fail("chat memory must survive routine-memory churn")

    [<TestMethod>]
    member _.NpcAvoidsImmediateRepeatChatPartner () =
        let needs = { Hunger = 100.0f; Energy = 100.0f; Social = 0.0f; Money = 100.0f }
        let personality =
            { Drive = 0.0f
              Aggression = 0.0f
              Extraversion = 1.0f
              Honesty = 0.0f
              Greed = 0.0f }
        let plaza = Sim.actionTarget Wander
        let npc id pos action age effectDone memory =
            { Id = NpcId id
              Pos = pos
              Mind =
                { Needs = needs
                  Personality = personality
                  Action = action
                  Target = Sim.actionTarget action
                  ActionAge = age
                  EffectDone = effectDone
                  HungerFlagged = false
                  Memory = memory } }
        let self = npc 0 plaza Wander Sim.minActionTicks true [ RelationHarness.chatted 0L 1 ]
        let nearest = npc 1 { X = plaza.X + 10.0f; Y = plaza.Y } Work 0L false []
        let alternate = npc 2 { X = plaza.X + 20.0f; Y = plaza.Y } Work 0L false []
        let world =
            { Tick = 1L
              Time = 0.0
              Rng = Rng.ofSeed 55UL
              Avatar = { Pos = plaza }
              NoHost = { Reserved = 0UL }
              Npcs = [| self; nearest; alternate |]
              Events = [] }
        let next = Sim.step RelationHarness.zeroInput world
        match List.tryHead next.Npcs.[0].Mind.Memory with
        | Some entry ->
            match entry.Kind with
            | Chatted(NpcId partner) ->
                if partner <> 2 then
                    Assert.Fail("a recent partner must not monopolize the next chat")
            | kind -> Assert.Fail($"expected a chat memory, got {kind}")
        | None -> Assert.Fail("expected a chat memory")