summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel
diff options
context:
space:
mode:
Diffstat (limited to 'src/LivingVillage.Kernel')
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs142
-rw-r--r--src/LivingVillage.Kernel/Sim.fs21
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs2
3 files changed, 161 insertions, 4 deletions
diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs
index 2168c4e..c207395 100644
--- a/src/LivingVillage.Kernel/Occupation.fs
+++ b/src/LivingVillage.Kernel/Occupation.fs
@@ -241,3 +241,145 @@ module Occupation =
/// Sim.quotePrice 的输出经职业偏置映射;None(无报价)原样穿透。
let biasedQuote (kind: Kind option) (playerIsBuyer: bool) (baseQuote: float32 option) : float32 option =
baseQuote |> Option.map (fun price -> price * quoteBiasOf kind playerIsBuyer)
+
+ // ---- 对话分支倾向(design §5 / P-d):村民对玩家职业的确定性回应偏差 ----
+ // 全部为纯函数重映射:基底仍走 Sim.responseFor 的人格口径,偏差不引入随机。
+ // 无职业(None)= 原回应逐字节不变。
+ // 规则(各自只作用于签名意图,其余意图原样穿透):
+ // 农夫 AskHelp:Refused → Helpful(老实人家,求助不落空);
+ // 渔夫 SmallTalk:Reserved → Friendly(聊的是天气鱼汛,冷场变热络);
+ // 货郎 SmallTalk:Reserved → Bargaining(三句不离买卖,更易进讨价还价);
+ // 书生 Joke:完全覆盖为 Reserved(读过书的人讲笑话,众人反而拘谨)——
+ // 与 personality 相反时采用完全覆盖语义而非概率偏向,确定性可测(design §5 反差感)。
+ let private remapResponse
+ (kind: Kind)
+ (intent: Sim.DialogueIntent)
+ (baseResponse: Sim.DialogueResponse)
+ : Sim.DialogueResponse =
+ match kind, intent, baseResponse with
+ | Farmer, Sim.AskHelp, Sim.Refused -> Sim.Helpful
+ | Fisher, Sim.SmallTalk, Sim.Reserved -> Sim.Friendly
+ | Peddler, Sim.SmallTalk, Sim.Reserved -> Sim.Bargaining
+ | Scholar, Sim.Joke, _ -> Sim.Reserved
+ | _ -> baseResponse
+
+ let biasResponse
+ (kind: Kind option)
+ (intent: Sim.DialogueIntent)
+ (personality: Sim.Personality)
+ : Sim.DialogueResponse =
+ let baseResponse = Sim.responseFor intent personality
+ match kind with
+ | None -> baseResponse
+ | Some kind -> remapResponse kind intent baseResponse
+
+ // ---- 轻剧情线(design §5 / P-d):三段式 接任务 → 走访 → 收束 ----
+ // 纯年鉴/对话驱动:每次玩家对话至多推进一段,产出只有(StoryStage 推进 + 年鉴摘要文本),
+ // 由调用方写入世界。全部输入 = (seed, 世界 tick, 关系状态, 职业状态),纯函数、确定性。
+ // 开线日沿用每日任务的 (seed, profession, day) splitmix 哈希模式。
+ module Story =
+
+ type Ending =
+ | Warm
+ | Plain
+ | Distant
+
+ /// 剧情线种子:与每日任务种子同源异值,两两不撞(见测试)。
+ let storySeedOf (kind: Kind) : uint64 =
+ match kind with
+ | Farmer -> 0x5555555555555555UL
+ | Fisher -> 0x6666666666666666UL
+ | Peddler -> 0x7777777777777777UL
+ | Scholar -> 0x8888888888888888UL
+
+ let lineNameOf (kind: Kind) : string =
+ match kind with
+ | Farmer -> "雨水与年成"
+ | Fisher -> "水位与收成"
+ | Peddler -> "新货的路子"
+ | Scholar -> "庙前的争论"
+
+ /// StoryStage 语义:0 未开始 / 1 已接任务 / 2 走访中 / 3 已收束。
+ let stageNameOf (stage: int) : string =
+ match stage with
+ | 1 -> "已接任务"
+ | 2 -> "走访中"
+ | 3 -> "已收束"
+ | _ -> "未开始"
+
+ /// 开线日 = hash(seed ^ storySeed, day 0) mod 3 + 2,落在 [2, 4]。
+ let startDayOf (seed: uint64) (kind: Kind) : int64 =
+ (int64 (taskHash seed (storySeedOf kind) 0L % 3UL)) + 2L
+
+ /// 玩家与村民的关系均值:与 Sim.relationMatrix 同口径(衰减 valence),
+ /// 但只统计认识玩家的村民(有 Chatted/Dialogue 记忆者),无一认识 = 0(中性)。
+ let relationMeanToPlayer (world: Sim.World) : float32 =
+ let player = Sim.playerId
+ let mutable total = 0.0f
+ let mutable known = 0
+ for npc in world.Npcs do
+ let mutable valence = 0.0f
+ let mutable touched = false
+ for e in npc.Mind.Memory do
+ if e.Tick <= world.Tick then
+ let involvesPlayer =
+ match e.Kind with
+ | Sim.Chatted partner -> partner = player
+ | Sim.Dialogue (actor, _, _) -> actor = player
+ | _ -> false
+ if involvesPlayer then
+ touched <- true
+ valence <- valence + e.Valence * Sim.relationDecayWeight world.Tick e.Tick
+ if touched then
+ known <- known + 1
+ total <- total + valence
+ if known = 0 then 0.0f else total / float32 known
+
+ /// 结局分档:均值 ≥ 0.3 热络;[0, 0.3) 平常;< 0 淡漠。纯阈值,无随机。
+ let endingOf (mean: float32) : Ending =
+ if mean >= 0.3f then Warm
+ elif mean >= 0.0f then Plain
+ else Distant
+
+ let endingTextOf (kind: Kind) (ending: Ending) : string =
+ match kind, ending with
+ | Farmer, Warm -> "雨水与年成有了结果:乡邻都说雨水匀,今年差不了"
+ | Farmer, Plain -> "雨水与年成有了结果:雨水尚可,年成将就,照旧耕作"
+ | Farmer, Distant -> "雨水与年成有了结果:问不出准信,年成只能看天"
+ | Fisher, Warm -> "水位与收成有了结果:乡邻说得真切,水位稳了,收成坏不了"
+ | Fisher, Plain -> "水位与收成有了结果:众说平常,水位尚可,收成将就"
+ | Fisher, Distant -> "水位与收成有了结果:问了几家都淡淡的,水情还得自己看"
+ | Peddler, Warm -> "新货的路子有了结果:乡邻热心指路,新货不愁卖"
+ | Peddler, Plain -> "新货的路子有了结果:说法平常,先小量试卖"
+ | Peddler, Distant -> "新货的路子有了结果:各家都淡淡的,路子还得自己闯"
+ | Scholar, Warm -> "庙前的争论有了结果:各说各理,事有分晓"
+ | Scholar, Plain -> "庙前的争论有了结果:众说纷纭,暂无定论"
+ | Scholar, Distant -> "庙前的争论有了结果:无人理会,先记在册上"
+
+ /// §3 身份条目:开局写入年鉴(如「以打渔为生」),不带主语、沿年鉴短句风格。
+ let identitySummaryOf (kind: Kind) : string =
+ match kind with
+ | Farmer -> "以耕田为生"
+ | Fisher -> "以打渔为生"
+ | Peddler -> "以贩货为生"
+ | Scholar -> "以读书为生"
+
+ /// 用一次玩家对话推进剧情线:同输入两次结果一致。
+ /// 返回(新状态, 应写入年鉴的摘要文本 option);无门控命中时原样返回。
+ /// 结局文本在收束瞬间按当时关系均值定格,由调用方连同世界存档(年鉴快照)持久化。
+ let onDialogue (seed: uint64) (state: State) (world: Sim.World) : State * string option =
+ let kind = state.Profile.Kind
+ let day = dayIndexOf world.Tick
+ let startDay = startDayOf seed kind
+ match state.StoryStage with
+ | 0 when day >= startDay ->
+ { state with StoryStage = 1 },
+ Some (sprintf "接了件事:%s,得去村里多问问" (lineNameOf kind))
+ | 1 when day >= startDay + 1L ->
+ { state with StoryStage = 2 },
+ Some "走访了几户人家,改日再问"
+ | 2 when day >= startDay + 2L ->
+ let ending = endingOf (relationMeanToPlayer world)
+ { state with StoryStage = 3 },
+ Some (endingTextOf kind ending)
+ | _ -> state, None
diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs
index 5c4e107..0ead9d5 100644
--- a/src/LivingVillage.Kernel/Sim.fs
+++ b/src/LivingVillage.Kernel/Sim.fs
@@ -153,6 +153,8 @@ module Sim =
| DialogueAnnal of DialogueOutcome
| RumorAnnal of RumorEvent
| TradeAnnal of InteractionEvent
+ /// P-d 剧情线/身份条目:文案直接落在 Summary(存档 token "story",仅带职业的 v3 存档产生)。
+ | StoryAnnal
type AnnalEntry =
{ Tick: int64
@@ -299,7 +301,8 @@ module Sim =
let clamp (v: float32) (lo: float32) (hi: float32) : float32 = if v < lo then lo elif v > hi then hi else v
- let private appendAnnal (entry: AnnalEntry) (world: World) : World =
+ /// P-d 起对调用方公开(剧情线年鉴写入复用同一裁剪口径,语义不变)。
+ let appendAnnal (entry: AnnalEntry) (world: World) : World =
{ world with Annals = (entry :: world.Annals) |> List.truncate maxAnnalEntries }
let hungerDecayPerTick = 0.0012f
@@ -765,7 +768,8 @@ module Sim =
{ Target = target
Options = dialogueOptions })
- let private responseFor (intent: DialogueIntent) (personality: Personality) : DialogueResponse =
+ /// 纯人格回应函数;P-d 起对调用方公开,供 Occupation.biasResponse 复用原口径。
+ let responseFor (intent: DialogueIntent) (personality: Personality) : DialogueResponse =
match intent with
| SmallTalk -> if personality.Extraversion >= 0.5f then Friendly else Reserved
| AskHelp -> if personality.Honesty >= 0.4f then Helpful else Refused
@@ -782,7 +786,12 @@ module Sim =
sprintf "rumor tick=%d source=%A narrator=%A receiver=%A depth=%d"
rumor.Tick rumor.Source rumor.Narrator rumor.Receiver rumor.Depth
- let chooseDialogue (target: NpcId) (intent: DialogueIntent) (world: World) : DialogueResult =
+ let chooseDialogueWith
+ (respond: DialogueIntent -> Personality -> DialogueResponse)
+ (target: NpcId)
+ (intent: DialogueIntent)
+ (world: World)
+ : DialogueResult =
match findNpcIndex target world.Npcs with
| None -> DialogueRejected(DialogueTargetNotFound, world)
| Some targetIndex ->
@@ -794,7 +803,7 @@ module Sim =
elif not (dialogueAvailable targetNpc) then
DialogueRejected(DialogueTargetUnavailable, world)
else
- let response = responseFor intent targetNpc.Mind.Personality
+ let response = respond intent targetNpc.Mind.Personality
let outcome =
{ Tick = world.Tick
Actor = playerId
@@ -842,6 +851,10 @@ module Sim =
withRumorAnnal
DialogueSucceeded(outcome, withDialogueAnnal)
+ /// 既有入口:无职业偏差的人格回应路径(identity,与历史行为逐字节一致)。
+ let chooseDialogue (target: NpcId) (intent: DialogueIntent) (world: World) : DialogueResult =
+ chooseDialogueWith responseFor target intent world
+
let annalText (world: World) : string =
let rows =
world.Annals
diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs
index b3bdfa9..7f724a3 100644
--- a/src/LivingVillage.Kernel/WorldSave.fs
+++ b/src/LivingVillage.Kernel/WorldSave.fs
@@ -390,12 +390,14 @@ module WorldSave =
| TradeAnnal event ->
add tokens "trade"
writeInteraction tokens event
+ | StoryAnnal -> add tokens "story"
let private readAnnalKind (reader: TokenReader) label =
match reader.Take(label) with
| "dialogue" -> DialogueAnnal(readDialogueOutcome reader (label + ".outcome"))
| "rumor" -> RumorAnnal(readRumor reader (label + ".rumor"))
| "trade" -> TradeAnnal(readInteraction reader (label + ".event"))
+ | "story" -> StoryAnnal
| _ -> invalid (sprintf "invalid %s" label)
let private writeAnnal tokens (value: AnnalEntry) =