summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-19 02:37:20 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-19 02:37:20 +0800
commit2f46d1789088eb165271bd915844376aa5a84768 (patch)
treefeeb8acc35333660652d9fe1052d6ea536d43bbf
parent4627a29302aa780ba8de1e537a869650601debc3 (diff)
downloadliving-village-2f46d1789088eb165271bd915844376aa5a84768.tar.gz
feat: M2b NPC 记忆与行为稳定化 — 环形记忆(≤64: tick/kind/valence) + 行动粘性(最小600tick, urgent<20豁免) + 夜间(22-6)sleep权重x3 + Desktop蓝色NPC与标题行动名 + headless摘要(mem_count/last_valence/行动平均持续/昼夜sleep占比) + 粘性与valence测试
-rw-r--r--src/LivingVillage.Desktop/Game.fs18
-rw-r--r--src/LivingVillage.Headless/Program.fs41
-rw-r--r--src/LivingVillage.Kernel.Tests/DeterminismTests.fs61
-rw-r--r--src/LivingVillage.Kernel/Sim.fs143
4 files changed, 240 insertions, 23 deletions
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 6e0feb6..e2b520f 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -63,6 +63,14 @@ module Atlas =
tex.SetData(colors)
tex
+module NpcView =
+ let actionName (k: NpcActionKind) : string =
+ match k with
+ | Eat -> "eat"
+ | Sleep -> "sleep"
+ | Wander -> "wander"
+ | Work -> "work"
+
type LivingVillageGame() as this =
inherit Game()
@@ -130,7 +138,11 @@ type LivingVillageGame() as this =
let fps = float fpsFrames / fpsSeconds
fpsFrames <- 0
fpsSeconds <- 0.0
- this.Window.Title <- $"Living Village M1{titleSuffix} | fps {fps:F1} | tick {world.Tick}"
+ let npcAction =
+ match world.Npcs with
+ | npc :: _ -> NpcView.actionName npc.Mind.Action
+ | [] -> "none"
+ this.Window.Title <- $"Living Village M1{titleSuffix} | fps {fps:F1} | tick {world.Tick} | npc {npcAction}"
printfn $"fps={fps:F1} tick={world.Tick} pos=({world.Avatar.Pos.X:F0},{world.Avatar.Pos.Y:F0})"
override this.Draw(gameTime: GameTime) =
@@ -152,4 +164,8 @@ type LivingVillageGame() as this =
let avatarDst =
Rectangle(int world.Avatar.Pos.X - camX, int world.Avatar.Pos.Y - camY, Sim.tilePixels, Sim.tilePixels)
spriteBatch.Draw(pixel, avatarDst, Color.IndianRed)
+ for npc in world.Npcs do
+ let npcDst =
+ Rectangle(int npc.Pos.X - camX, int npc.Pos.Y - camY, Sim.tilePixels, Sim.tilePixels)
+ spriteBatch.Draw(pixel, npcDst, Color.Blue)
spriteBatch.End()
diff --git a/src/LivingVillage.Headless/Program.fs b/src/LivingVillage.Headless/Program.fs
index 8c2b6d6..6f86f45 100644
--- a/src/LivingVillage.Headless/Program.fs
+++ b/src/LivingVillage.Headless/Program.fs
@@ -57,6 +57,14 @@ let runSimulation (days: int64) (seed: uint64) : int =
let mutable nonFinite = 0L
let mutable outOfBounds = 0L
let mutable t = 0L
+ let mutable lastAction = (List.head world.Npcs).Mind.Action
+ let mutable lastStart = 0L
+ let mutable switchCount = 0L
+ let mutable switchDurSum = 0L
+ let mutable sleepNight = 0L
+ let mutable sleepDay = 0L
+ let mutable nightTicks = 0L
+ let mutable dayTicks = 0L
while t < totalTicks do
let d, input =
if t < driver.Until then driver, driver.Input
@@ -66,21 +74,48 @@ let runSimulation (days: int64) (seed: uint64) : int =
driver <- d
world <- Sim.step { Input = input } world
t <- t + 1L
+ let nightNow = Sim.isNightTick t
+ if nightNow then nightTicks <- nightTicks + 1L else dayTicks <- dayTicks + 1L
+ let head = List.head world.Npcs
+ if head.Mind.Action = Sleep then
+ if nightNow then sleepNight <- sleepNight + 1L else sleepDay <- sleepDay + 1L
+ if head.Mind.Action <> lastAction then
+ switchCount <- switchCount + 1L
+ switchDurSum <- switchDurSum + (t - lastStart)
+ lastStart <- t
+ lastAction <- head.Mind.Action
let p = world.Avatar.Pos
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 % 2000L = 0L then
let day = float t / float ticksPerDay
- let mem = GC.GetTotalMemory(true)
+ let memTotal = GC.GetTotalMemory(true)
for npc in world.Npcs do
let n = npc.Mind.Needs
let p' = npc.Pos
- printfn "npc=%A tick=%d day=%.4f pos=(%.1f,%.1f) hunger=%.2f energy=%.2f social=%.2f money=%.2f action=%s"
- npc.Id t day p'.X p'.Y n.Hunger n.Energy n.Social n.Money (actionName npc.Mind.Action)
+ let memCount = List.length npc.Mind.Memory
+ let lastValence =
+ match List.tryHead npc.Mind.Memory with
+ | Some e -> e.Valence
+ | None -> 0.0f
+ printfn "npc=%A tick=%d day=%.4f pos=(%.1f,%.1f) hunger=%.2f energy=%.2f social=%.2f money=%.2f action=%s mem_count=%d last_valence=%.2f"
+ npc.Id t day p'.X p'.Y n.Hunger n.Energy n.Social n.Money (actionName npc.Mind.Action) memCount lastValence
if t % 1000L = 0L && t % 2000L <> 0L then
let day = float t / float ticksPerDay
printfn "tick=%d time=%.4fs day=%.4f pos=(%.1f,%.1f) rng=%016x" t world.Time day p.X p.Y world.Rng.State
+ 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 = List.head world.Npcs
+ 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
printfn "done tick=%d non-finite=%d out-of-bounds=%d" world.Tick nonFinite outOfBounds
if nonFinite > 0L || outOfBounds > 0L then 1 else 0
diff --git a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs
index aba794e..0e1766e 100644
--- a/src/LivingVillage.Kernel.Tests/DeterminismTests.fs
+++ b/src/LivingVillage.Kernel.Tests/DeterminismTests.fs
@@ -180,3 +180,64 @@ type DeterminismTests () =
seqB.Add tb.Npcs.[0].Mind.Action
if Seq.compareWith compare seqA seqB = 0 then
Assert.Fail("npc action sequence must diverge across seeds")
+
+ [<TestMethod>]
+ member _.NpcActionPersistsAtLeastMinTicks () =
+ let zero = { Input = { MoveX = 0.0f; MoveY = 0.0f } }
+ let mutable w = Sim.initialWorld 42UL
+ let total = 30000
+ let mutable current = w.Npcs.[0].Mind.Action
+ let mutable prevNeeds = w.Npcs.[0].Mind.Needs
+ let mutable age = 0
+ let mutable minStickyAge = System.Int32.MaxValue
+ let mutable switches = 0
+ let mutable urgentSwitches = 0
+ for _ in 1 .. total do
+ w <- Sim.step zero w
+ let npc = w.Npcs.[0]
+ let a = npc.Mind.Action
+ if a <> current then
+ let decayed =
+ { Hunger = prevNeeds.Hunger - Sim.hungerDecayPerTick
+ Energy = prevNeeds.Energy - Sim.energyDecayPerTick
+ Social = prevNeeds.Social - Sim.socialDecayPerTick
+ Money = prevNeeds.Money - Sim.moneyDecayPerTick }
+ if Sim.needsUrgent decayed then urgentSwitches <- urgentSwitches + 1
+ else minStickyAge <- min minStickyAge age
+ current <- a
+ age <- 0
+ switches <- switches + 1
+ else
+ age <- age + 1
+ prevNeeds <- npc.Mind.Needs
+ Assert.IsTrue(switches > 0, "expected at least one action switch in 30000 ticks")
+ Assert.IsTrue(minStickyAge >= 600, $"non-urgent action must persist >= 600 ticks, got min {minStickyAge}")
+
+ [<TestMethod>]
+ member _.NpcRecordsValenceMemoryOnActionEffect () =
+ let zero = { Input = { MoveX = 0.0f; MoveY = 0.0f } }
+ let mutable w = Sim.initialWorld 42UL
+ let mutable sawEmpty = false
+ let mutable sawEvent = false
+ for _ in 1 .. 6000 do
+ w <- Sim.step zero w
+ let npc = w.Npcs.[0]
+ if npc.Mind.Memory.IsEmpty then sawEmpty <- true
+ if npc.Mind.Memory.Length > Sim.memoryCapacity then
+ Assert.Fail("memory exceeds ring capacity")
+ match List.tryHead npc.Mind.Memory with
+ | Some e ->
+ let expected =
+ match e.Kind with
+ | Meal -> 0.5f
+ | Rest -> 0.4f
+ | Pay -> 0.2f
+ | Hungry -> -0.4f
+ if e.Valence <> expected then
+ Assert.Fail($"valence/kind mismatch: {e.Kind} -> {e.Valence}")
+ if e.Valence < -1.0f || e.Valence > 1.0f then
+ Assert.Fail($"valence out of [-1,1]: {e.Valence}")
+ if e.Tick >= 1L && e.Tick <= 6000L then sawEvent <- true
+ | None -> ()
+ Assert.IsTrue(sawEmpty, "memory must start empty")
+ Assert.IsTrue(sawEvent, "expected a valence memory event within 6000 ticks")
diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs
index fac9c58..6ba48e2 100644
--- a/src/LivingVillage.Kernel/Sim.fs
+++ b/src/LivingVillage.Kernel/Sim.fs
@@ -47,12 +47,27 @@ module Sim =
| Wander
| Work
+ type MemoryKind =
+ | Meal
+ | Rest
+ | Pay
+ | Hungry
+
+ type MemoryEvent =
+ { Tick: int64
+ Kind: MemoryKind
+ Valence: float32 }
+
[<Struct>]
type Mind =
{ Needs: Needs
Personality: Personality
Action: NpcActionKind
- Target: Vec2 }
+ Target: Vec2
+ ActionAge: int64
+ EffectDone: bool
+ HungerFlagged: bool
+ Memory: MemoryEvent list }
[<Struct>]
type Npc =
@@ -83,6 +98,11 @@ module Sim =
let npcSpeed = 80.0f
let arriveEpsilon = 2.0f
+ let memoryCapacity = 64
+ let minActionTicks = 600L
+ let urgentThreshold = 20.0f
+ let nightSleepMultiplier = 3.0f
+
let clamp v lo hi = if v < lo then lo elif v > hi then hi else v
let hungerDecayPerTick = 0.0012f
@@ -106,6 +126,29 @@ module Sim =
| Wander -> plazaPoint
| Work -> worksitePoint
+ let isNightTick (tick: int64) : bool =
+ let hourTicks = ticksPerDay / 24L
+ let tod = tick % ticksPerDay
+ tod >= 22L * hourTicks || tod < 6L * hourTicks
+
+ let valenceOfKind (kind: MemoryKind) : float32 =
+ match kind with
+ | Meal -> 0.5f
+ | Rest -> 0.4f
+ | Pay -> 0.2f
+ | Hungry -> -0.4f
+
+ let recordMemory (tick: int64) (kind: MemoryKind) (mem: MemoryEvent list) : MemoryEvent list =
+ { Tick = tick; Kind = kind; Valence = valenceOfKind kind } :: mem
+ |> List.truncate memoryCapacity
+
+ let actionMemoryKind (kind: NpcActionKind) : MemoryKind option =
+ match kind with
+ | Eat -> Some Meal
+ | Sleep -> Some Rest
+ | Work -> Some Pay
+ | Wander -> None
+
let personalityOfSeed (seed: uint64) : Personality =
let a, r1 = Rng.nextFloat32 (Rng.ofSeed seed)
let b, r2 = Rng.nextFloat32 r1
@@ -124,16 +167,24 @@ module Sim =
Social = clamp n.Social 0.0f 100.0f
Money = clamp n.Money 0.0f 100.0f }
- let scoreAction (needs: Needs) (p: Personality) (kind: NpcActionKind) : float32 =
- match kind with
- | Eat -> (100.0f - needs.Hunger) * (0.25f + 2.0f * p.Drive)
- | Sleep -> (100.0f - needs.Energy) * (0.25f + 2.0f * (1.0f - p.Drive))
- | Wander -> (100.0f - needs.Social) * (0.25f + 2.0f * p.Extraversion)
- | Work -> (100.0f - needs.Money) * (0.25f + 2.0f * p.Greed)
+ let scoreAction (night: bool) (needs: Needs) (p: Personality) (kind: NpcActionKind) : float32 =
+ let raw =
+ match kind with
+ | Eat -> (100.0f - needs.Hunger) * (0.25f + 2.0f * p.Drive)
+ | Sleep -> (100.0f - needs.Energy) * (0.25f + 2.0f * (1.0f - p.Drive))
+ | Wander -> (100.0f - needs.Social) * (0.25f + 2.0f * p.Extraversion)
+ | Work -> (100.0f - needs.Money) * (0.25f + 2.0f * p.Greed)
+ if kind = Sleep && night then raw * nightSleepMultiplier else raw
- let decideAction (needs: Needs) (p: Personality) : NpcActionKind =
+ let decideAction (night: bool) (needs: Needs) (p: Personality) : NpcActionKind =
let candidates = [ Eat; Sleep; Wander; Work ]
- candidates |> List.maxBy (scoreAction needs p)
+ candidates |> List.maxBy (scoreAction night needs p)
+
+ let needsUrgent (n: Needs) : bool =
+ n.Hunger < urgentThreshold
+ || n.Energy < urgentThreshold
+ || n.Social < urgentThreshold
+ || n.Money < urgentThreshold
let applyActionEffect (kind: NpcActionKind) (n: Needs) : Needs =
match kind with
@@ -157,11 +208,19 @@ module Sim =
let centerY = float32 (mapHeightTiles * tilePixels / 2 - tilePixels / 2)
let personality = personalityOfSeed seed
let needs = { Hunger = 100.0f; Energy = 100.0f; Social = 100.0f; Money = 50.0f }
- let startAction = decideAction needs personality
+ let startAction = decideAction (isNightTick 0L) needs personality
let npc =
{ Id = NpcId 0
Pos = { X = centerX; Y = centerY }
- Mind = { Needs = needs; Personality = personality; Action = startAction; Target = actionTarget startAction } }
+ Mind =
+ { Needs = needs
+ Personality = personality
+ Action = startAction
+ Target = actionTarget startAction
+ ActionAge = 0L
+ EffectDone = false
+ HungerFlagged = false
+ Memory = [] } }
{ Tick = 0L
Time = 0.0
Rng = Rng.ofSeed seed
@@ -169,22 +228,68 @@ module Sim =
NoHost = { Reserved = 0UL }
Npcs = [ npc ] }
- let private stepNpc (npc: Npc) : Npc =
+ let private stepNpc (tick: int64) (npc: Npc) : Npc =
let decayed =
needsClamp
{ Hunger = npc.Mind.Needs.Hunger - hungerDecayPerTick
Energy = npc.Mind.Needs.Energy - energyDecayPerTick
Social = npc.Mind.Needs.Social - socialDecayPerTick
Money = npc.Mind.Needs.Money - moneyDecayPerTick }
- let mind0 = { npc.Mind with Needs = decayed }
- let target = actionTarget mind0.Action
+ let night = isNightTick tick
+ let age = npc.Mind.ActionAge + 1L
+ let urgent = needsUrgent decayed
+ let target = actionTarget npc.Mind.Action
let nextPos, arrived = moveToward npc.Pos target (npcSpeed * dtSecondsF)
+ let mind0 =
+ { npc.Mind with
+ Needs = decayed
+ ActionAge = age
+ HungerFlagged =
+ if decayed.Hunger < urgentThreshold then npc.Mind.HungerFlagged else false }
+ let mind1 =
+ if decayed.Hunger < urgentThreshold && not npc.Mind.HungerFlagged then
+ { mind0 with Memory = recordMemory tick Hungry mind0.Memory; HungerFlagged = true }
+ else mind0
if arrived then
- let needs = applyActionEffect mind0.Action mind0.Needs
- let action = decideAction needs mind0.Personality
- { npc with Pos = target; Mind = { Needs = needs; Personality = mind0.Personality; Action = action; Target = actionTarget action } }
+ let needs2, mem2 =
+ if mind1.EffectDone then mind1.Needs, mind1.Memory
+ else
+ let mem =
+ match actionMemoryKind mind1.Action with
+ | Some k -> recordMemory tick k mind1.Memory
+ | None -> mind1.Memory
+ applyActionEffect mind1.Action mind1.Needs, mem
+ let mind2 = { mind1 with Needs = needs2; Memory = mem2; EffectDone = true }
+ if age >= minActionTicks || urgent then
+ let action = decideAction night needs2 mind2.Personality
+ if action = mind2.Action then
+ { npc with Pos = nextPos; Mind = { mind2 with ActionAge = 0L; EffectDone = false } }
+ else
+ { npc with
+ Pos = nextPos
+ Mind =
+ { mind2 with
+ Action = action
+ Target = actionTarget action
+ ActionAge = 0L
+ EffectDone = false } }
+ else
+ { npc with Pos = nextPos; Mind = mind2 }
+ elif urgent && age < minActionTicks then
+ let action = decideAction night decayed mind1.Personality
+ if action = mind1.Action then
+ { npc with Pos = nextPos; Mind = mind1 }
+ else
+ { npc with
+ Pos = nextPos
+ Mind =
+ { mind1 with
+ Action = action
+ Target = actionTarget action
+ ActionAge = 0L
+ EffectDone = false } }
else
- { npc with Pos = nextPos; Mind = mind0 }
+ { npc with Pos = nextPos; Mind = mind1 }
let step (ts: TimeStep) (world: World) : World =
let tick = world.Tick + 1L
@@ -202,4 +307,4 @@ module Sim =
{ X = clamp (world.Avatar.Pos.X + dx) 0.0f maxX
Y = clamp (world.Avatar.Pos.Y + dy) 0.0f maxY } }
NoHost = world.NoHost
- Npcs = world.Npcs |> List.map stepNpc }
+ Npcs = world.Npcs |> List.map (stepNpc tick) }