diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 16:16:40 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 16:16:40 +0800 |
| commit | 6c2f180cc7c8315e32d88cbe5d44928d495fe473 (patch) | |
| tree | b882d707e1ac1b31c885b19e1f2ce31534448c52 | |
| parent | 2f915a48837039dc772d7fa25b83b34a707922fd (diff) | |
| download | living-village-6c2f180cc7c8315e32d88cbe5d44928d495fe473.tar.gz | |
feat(kernel): P31 step1 occupation dialogue response bias
- Occupation.biasResponse: deterministic pure remap over Sim.responseFor;
no occupation = byte-identical pass-through (all intents x personalities).
farmer AskHelp Refused->Helpful; fisher SmallTalk Reserved->Friendly;
peddler SmallTalk Reserved->Bargaining; scholar Joke fully overridden to
Reserved (documented total-override semantics, opposite of personality).
- Sim.responseFor made public (same pure function, reused as the base path).
- Sim.chooseDialogueWith: extracted body of chooseDialogue with an injectable
respond function; chooseDialogue keeps identity semantics unchanged.
- Sim.appendAnnal made public for the upcoming story annal writes.
- 9 new kernel tests (92 -> 101); performance digest unchanged.
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/OccupationBiasTests.fs | 146 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 31 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 19 |
4 files changed, 193 insertions, 4 deletions
diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj index dea8314..6924012 100644 --- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj +++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj @@ -14,6 +14,7 @@ <Compile Include="M6aTests.fs" /> <Compile Include="DailyTaskTests.fs" /> <Compile Include="TradeBiasTests.fs" /> + <Compile Include="OccupationBiasTests.fs" /> <Compile Include="PerformanceTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Kernel.Tests/OccupationBiasTests.fs b/src/LivingVillage.Kernel.Tests/OccupationBiasTests.fs new file mode 100644 index 0000000..76e02f3 --- /dev/null +++ b/src/LivingVillage.Kernel.Tests/OccupationBiasTests.fs @@ -0,0 +1,146 @@ +namespace LivingVillage.Kernel.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim + +module private BiasHarness = + + /// 覆盖 responseFor 全部分支的两个人格样本: + /// 外向+诚实(E>=0.5, H>=0.4)与 内向+不诚实(E<0.5, H<0.4)。 + let outgoing = { Drive = 0.5f; Aggression = 0.2f; Extraversion = 0.8f; Honesty = 0.9f; Greed = 0.3f } + let withdrawn = { Drive = 0.5f; Aggression = 0.2f; Extraversion = 0.2f; Honesty = 0.1f; Greed = 0.3f } + let grumpy = { Drive = 0.5f; Aggression = 0.8f; Extraversion = 0.2f; Honesty = 0.1f; Greed = 0.3f } + + let atNpc (id: int) (world: World) : World = + let npc = world.Npcs.[id] + { world with Avatar = { world.Avatar with Pos = npc.Pos } } + +[<TestClass>] +type OccupationBiasTests () = + + // ---- 无职业 = 既有 responseFor 输出逐字节不变 ---- + + [<TestMethod>] + member _.NoOccupationReproducesTheHistoricResponsesForEveryIntent () = + for intent in [ SmallTalk; AskHelp; OfferTrade; Joke; Apologize; Provoke ] do + for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn; BiasHarness.grumpy ] do + Assert.IsTrue( + Sim.responseFor intent personality = Occupation.biasResponse None intent personality, + $"no-occupation bias must be identity for {intent}") + + // ---- 农夫 AskHelp:Refused → Helpful(求助更友好)---- + + [<TestMethod>] + member _.FarmerAskHelpTurnsRefusalIntoHelpfulness () = + // 不诚实村民对常人 Refused,对农夫给 Helpful + Assert.AreEqual<DialogueResponse>(Refused, Sim.responseFor AskHelp BiasHarness.withdrawn) + Assert.AreEqual<DialogueResponse>(Helpful, Occupation.biasResponse (Some Occupation.Farmer) AskHelp BiasHarness.withdrawn) + // 本就 Helpful 的路径不变 + Assert.AreEqual<DialogueResponse>(Helpful, Sim.responseFor AskHelp BiasHarness.outgoing) + Assert.AreEqual<DialogueResponse>(Helpful, Occupation.biasResponse (Some Occupation.Farmer) AskHelp BiasHarness.outgoing) + + // ---- 渔夫 SmallTalk:Reserved → Friendly(聊天气鱼汛,冷场变热络)---- + + [<TestMethod>] + member _.FisherSmallTalkWarmsUpAReservedVillager () = + Assert.AreEqual<DialogueResponse>(Reserved, Sim.responseFor SmallTalk BiasHarness.withdrawn) + Assert.AreEqual<DialogueResponse>(Friendly, Occupation.biasResponse (Some Occupation.Fisher) SmallTalk BiasHarness.withdrawn) + Assert.AreEqual<DialogueResponse>(Friendly, Sim.responseFor SmallTalk BiasHarness.outgoing) + Assert.AreEqual<DialogueResponse>(Friendly, Occupation.biasResponse (Some Occupation.Fisher) SmallTalk BiasHarness.outgoing) + + // ---- 货郎 SmallTalk:Reserved → Bargaining(三句不离买卖,更易进讨价还价)---- + + [<TestMethod>] + member _.PeddlerSmallTalkSlidesIntoBargaining () = + Assert.AreEqual<DialogueResponse>(Reserved, Sim.responseFor SmallTalk BiasHarness.withdrawn) + Assert.AreEqual<DialogueResponse>(Bargaining, Occupation.biasResponse (Some Occupation.Peddler) SmallTalk BiasHarness.withdrawn) + // 外向村民本就 Friendly,不被货郎偏差改写 + Assert.AreEqual<DialogueResponse>(Friendly, Occupation.biasResponse (Some Occupation.Peddler) SmallTalk BiasHarness.outgoing) + + // ---- 书生 Joke:完全覆盖为 Reserved(与 personality 相反的确定性偏差)---- + // 语义说明:书生讲笑话走「完全覆盖」——无论村民性格如何,回应一律 Reserved。 + // 刻意不做概率偏向(禁随机);反差感即设计意图(design §5)。 + + [<TestMethod>] + member _.ScholarJokeIsAlwaysReservedRegardlessOfPersonality () = + Assert.AreEqual<DialogueResponse>(Amused, Sim.responseFor Joke BiasHarness.outgoing) + Assert.AreEqual<DialogueResponse>(Reserved, Sim.responseFor Joke BiasHarness.withdrawn) + for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn; BiasHarness.grumpy ] do + Assert.AreEqual<DialogueResponse>( + Reserved, + Occupation.biasResponse (Some Occupation.Scholar) Joke personality) + + // ---- 偏差只作用于各自的签名意图,其余意图原样穿透 ---- + + [<TestMethod>] + member _.BiasOnlyAppliesToTheSignatureIntents () = + // 农夫:仅 AskHelp 受偏差 + for intent in [ SmallTalk; OfferTrade; Joke; Apologize; Provoke ] do + for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn ] do + Assert.AreEqual<DialogueResponse>( + Sim.responseFor intent personality, + Occupation.biasResponse (Some Occupation.Farmer) intent personality) + // 渔夫:仅 SmallTalk 受偏差 + for intent in [ AskHelp; OfferTrade; Joke; Apologize; Provoke ] do + for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn ] do + Assert.AreEqual<DialogueResponse>( + Sim.responseFor intent personality, + Occupation.biasResponse (Some Occupation.Fisher) intent personality) + // 货郎:仅 SmallTalk 受偏差 + for intent in [ AskHelp; OfferTrade; Joke; Apologize; Provoke ] do + for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn ] do + Assert.AreEqual<DialogueResponse>( + Sim.responseFor intent personality, + Occupation.biasResponse (Some Occupation.Peddler) intent personality) + // 书生:仅 Joke 受偏差 + for intent in [ SmallTalk; AskHelp; OfferTrade; Apologize; Provoke ] do + for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn ] do + Assert.AreEqual<DialogueResponse>( + Sim.responseFor intent personality, + Occupation.biasResponse (Some Occupation.Scholar) intent personality) + + // ---- 确定性:同输入两次调用结果一致 ---- + + [<TestMethod>] + member _.BiasIsDeterministicForTheSameInputs () = + for kind in Occupation.all do + for intent in [ SmallTalk; AskHelp; OfferTrade; Joke; Apologize; Provoke ] do + for personality in [ BiasHarness.outgoing; BiasHarness.withdrawn; BiasHarness.grumpy ] do + let first = Occupation.biasResponse (Some kind) intent personality + let second = Occupation.biasResponse (Some kind) intent personality + Assert.AreEqual<DialogueResponse>(first, second) + + // ---- 与 chooseDialogueWith 复合:偏差写进 DialogueOutcome/记忆/年鉴 ---- + + [<TestMethod>] + member _.ChooseDialogueWithIdentityMatchesTheHistoricPath () = + let world = Sim.initialWorldN 42UL 4 |> BiasHarness.atNpc 0 + let npc = world.Npcs.[0] + let identityOutcome = + match Sim.chooseDialogueWith Sim.responseFor npc.Id AskHelp world with + | DialogueSucceeded (outcome, _) -> outcome + | DialogueRejected (failure, _) -> Assert.Fail($"rejected: {failure}"); Unchecked.defaultof<DialogueOutcome> + Assert.AreEqual<DialogueResponse>(Sim.responseFor AskHelp npc.Mind.Personality, identityOutcome.Response) + + [<TestMethod>] + member _.ChooseDialogueWithBiasRecordsTheBiasedResponse () = + let world = Sim.initialWorldN 42UL 4 |> BiasHarness.atNpc 0 + let npc = world.Npcs.[0] + let biasedRespond = Occupation.biasResponse (Some Occupation.Farmer) + let response, outcomeWorld = + match Sim.chooseDialogueWith biasedRespond npc.Id AskHelp world with + | DialogueSucceeded (outcome, next) -> + Assert.AreEqual<DialogueResponse>( + Occupation.biasResponse (Some Occupation.Farmer) AskHelp npc.Mind.Personality, + outcome.Response) + outcome.Response, next + | DialogueRejected (failure, _) -> Assert.Fail($"rejected: {failure}"); Unchecked.defaultof<_>, Unchecked.defaultof<World> + // 目标村民记忆里记录的是偏差后的回应 + let remembered = + outcomeWorld.Npcs.[0].Mind.Memory + |> List.tryPick (fun memory -> + match memory.Kind with + | Dialogue (_, _, response) -> Some response + | _ -> None) + Assert.AreEqual<DialogueResponse option>(Some response, remembered) diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs index 2168c4e..8ce5060 100644 --- a/src/LivingVillage.Kernel/Occupation.fs +++ b/src/LivingVillage.Kernel/Occupation.fs @@ -241,3 +241,34 @@ 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 diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs index 5c4e107..348a60e 100644 --- a/src/LivingVillage.Kernel/Sim.fs +++ b/src/LivingVillage.Kernel/Sim.fs @@ -299,7 +299,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 +766,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 +784,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 +801,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 +849,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 |
