summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Kernel/Sim.fs66
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs1
2 files changed, 57 insertions, 10 deletions
diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs
index 5445e7d..fd50fed 100644
--- a/src/LivingVillage.Kernel/Sim.fs
+++ b/src/LivingVillage.Kernel/Sim.fs
@@ -95,6 +95,9 @@ module Sim =
type RumorEvent =
{ Id: RumorId
Tick: int64
+ /// 该谣言所属模拟日(= Tick / ticksPerDay)。派生镜像字段,不参与存档序列化,
+ /// 以便按天确定性淘汰工作集;读档时由 Tick 纯函数重建,故 v1/v2/v3 存档逐字节不变。
+ DayIndex: int64
OriginTick: int64
Source: NpcId
Narrator: NpcId
@@ -269,6 +272,17 @@ module Sim =
let rumorHalfLifeTicks = ticksPerDay
let rumorMinimumStrength = 0.125f
+ /// 谣言工作集容量上界(参数化常量;测试可临时调小)。默认值远大于任何既有测试
+ /// 路径产生的谣言数,因此 append-only 前 N 条内与未裁剪版本逐字节等价;超界只保留最新 N 条。
+ let mutable rumorCapacity = 16384
+
+ /// 按天淘汰的保留天数。>= 新鲜窗口(rumorFreshnessTicks / ticksPerDay = 3 天),
+ /// 保证任何仍可能被 latestRumorFor/rumorIsDuplicate 使用的新鲜谣言都不会被裁掉。
+ let mutable rumorRetentionDays = 3L
+
+ /// 谣言所属模拟日(纯函数)。用于确定性的按天淘汰,读档时与 Tick 保持一致。
+ let rumorDayIndex (tick: int64) : int64 = tick / ticksPerDay
+
// 关系网派生(纯函数;仅在 dump/视图打开时调用,不进 step 热路径)
let relationHalfLifeTicks = ticksPerDay // HALF_LIFE = 1 模拟日(修复单位错误:原 86400L 实为 24 模拟分钟;1 模拟日 = ticksPerDay = 5,184,000 tick)
let relationThreshold = 0.5f // |rel| > 0.5 视为有关系
@@ -643,25 +657,54 @@ module Sim =
let rumorStrengthAt (nowTick: int64) (rumor: RumorEvent) : float32 =
rumor.Strength * rumorDecayWeight nowTick rumor.Tick
+ /// 判断工作集是否需要裁剪:超过容量或存在早于保留窗口的条目。
+ /// 列表约定为最新在前,故只需从前缀扫描 min(len, capacity+1) 条,无分配。
+ let private rumorsNeedTrim (cutoffDay: int64) (rumors: RumorEvent list) : bool =
+ let rec loop seen rest =
+ if seen > rumorCapacity then true
+ else
+ match rest with
+ | [] -> false
+ | rumor :: tail ->
+ if rumor.DayIndex < cutoffDay then true else loop (seen + 1) tail
+ loop 1 rumors
+
+ /// 纯函数裁剪:保留最新 rumorCapacity 条,并丢弃早于 (nowDay - rumorRetentionDays)
+ /// 的条目。容量内且全部新鲜时原样返回(零拷贝,保证既有路径逐字节不变)。
+ let trimRumors (nowTick: int64) (rumors: RumorEvent list) : RumorEvent list =
+ let cutoffDay = max 0L (rumorDayIndex nowTick - rumorRetentionDays)
+ if rumorsNeedTrim cutoffDay rumors then
+ rumors
+ |> List.takeWhile (fun rumor -> rumor.DayIndex >= cutoffDay)
+ |> List.truncate rumorCapacity
+ else
+ rumors
+
+ /// 追加一条谣言并顺手裁剪工作集;容量内且新鲜时等价于 `rumor :: rumors`。
+ let private consRumor (nowTick: int64) (rumor: RumorEvent) (rumors: RumorEvent list) : RumorEvent list =
+ trimRumors nowTick (rumor :: rumors)
+
let private latestRumorFor (receiver: NpcId) (world: World) : RumorEvent option =
+ // 工作集最新在前且 Tick 非递增,故只需扫描新鲜窗口;窗口外条目本就会被过滤掉。
world.Rumors
+ |> List.takeWhile (fun rumor -> world.Tick - rumor.Tick <= rumorFreshnessTicks)
|> List.filter (fun rumor ->
rumor.Receiver = receiver
&& rumor.Tick <= world.Tick
- && world.Tick - rumor.Tick <= rumorFreshnessTicks
&& rumorStrengthAt world.Tick rumor >= rumorMinimumStrength)
|> List.sortByDescending (fun rumor ->
let (RumorId id) = rumor.Id
rumor.Tick, id)
|> List.tryHead
+ /// 工作集最新在前,头节点恒为最大 Id(每次追加 `max+1`),故 O(1) 取下一个 Id。
+ /// 裁剪只丢尾部旧条目,不会丢头,因此该不变量在裁剪后仍成立。
let private nextRumorId (world: World) : RumorId =
- let maxId =
- world.Rumors
- |> List.fold (fun current rumor ->
- let (RumorId id) = rumor.Id
- max current id) -1L
- RumorId(maxId + 1L)
+ match world.Rumors with
+ | [] -> RumorId 0L
+ | head :: _ ->
+ let (RumorId id) = head.Id
+ RumorId(id + 1L)
let private dialogueAvailable (npc: Npc) : bool =
npc.Mind.Action <> Sleep && npc.Mind.Action <> Chat
@@ -723,6 +766,7 @@ module Sim =
let rumor =
{ Id = nextRumorId world
Tick = world.Tick
+ DayIndex = rumorDayIndex world.Tick
OriginTick = world.Tick
Source = playerId
Narrator = playerId
@@ -742,7 +786,7 @@ module Sim =
Avatar = { world.Avatar with Mind = { world.Avatar.Mind with Memory = avatarMemory } }
Npcs = newNpcs
Events = world.Events @ [ event ]
- Rumors = rumor :: world.Rumors }
+ Rumors = consRumor world.Tick rumor world.Rumors }
let withRumorAnnal =
appendAnnal
{ Tick = world.Tick
@@ -783,12 +827,12 @@ module Sim =
let private rumorIsDuplicate (request: ChatRequest) (parent: RumorEvent option) (world: World) : bool =
let parentId = parent |> Option.map (fun rumor -> rumor.Id)
world.Rumors
+ |> List.takeWhile (fun rumor -> world.Tick - rumor.Tick <= rumorFreshnessTicks)
|> List.exists (fun rumor ->
rumor.Narrator = request.Narrator
&& rumor.Receiver = request.Receiver
&& rumor.Parent = parentId
&& rumor.Tick <= world.Tick
- && world.Tick - rumor.Tick <= rumorFreshnessTicks
&& rumorStrengthAt world.Tick rumor >= rumorMinimumStrength)
type private ChatParticipant =
@@ -855,6 +899,7 @@ module Sim =
| None ->
{ Id = nextRumorId world
Tick = world.Tick
+ DayIndex = rumorDayIndex world.Tick
OriginTick = world.Tick
Source = request.Narrator
Narrator = request.Narrator
@@ -865,6 +910,7 @@ module Sim =
| Some parent ->
{ Id = nextRumorId world
Tick = world.Tick
+ DayIndex = rumorDayIndex world.Tick
OriginTick = parent.OriginTick
Source = parent.Source
Narrator = request.Narrator
@@ -884,7 +930,7 @@ module Sim =
{ npc with
Mind = { npc.Mind with Memory = recordMemory world.Tick (Rumor rumor.Id) npc.Mind.Memory } }
{ chatted with Npcs = newNpcs }
- let updated = { withReceiverRumor with Rumors = rumor :: world.Rumors }
+ let updated = { withReceiverRumor with Rumors = consRumor world.Tick rumor world.Rumors }
ChatSucceeded(
Some rumor,
appendAnnal
diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs
index b327c6d..f1b0be3 100644
--- a/src/LivingVillage.Kernel/WorldSave.fs
+++ b/src/LivingVillage.Kernel/WorldSave.fs
@@ -265,6 +265,7 @@ module WorldSave =
| _ -> invalid (sprintf "invalid %s parent" label)
{ Id = id
Tick = tick
+ DayIndex = rumorDayIndex tick
OriginTick = originTick
Source = source
Narrator = narrator