diff options
Diffstat (limited to 'src/LivingVillage.Kernel')
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 142 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 21 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/WorldSave.fs | 2 |
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) = |
