summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj1
-rw-r--r--src/LivingVillage.Kernel.Tests/OccupationBiasTests.fs146
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs31
-rw-r--r--src/LivingVillage.Kernel/Sim.fs19
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