diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-19 02:37:20 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-19 02:37:20 +0800 |
| commit | 2f46d1789088eb165271bd915844376aa5a84768 (patch) | |
| tree | feeb8acc35333660652d9fe1052d6ea536d43bbf /src | |
| parent | 4627a29302aa780ba8de1e537a869650601debc3 (diff) | |
| download | living-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测试
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 18 | ||||
| -rw-r--r-- | src/LivingVillage.Headless/Program.fs | 41 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/DeterminismTests.fs | 61 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 143 |
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) } |
