namespace LivingVillage.Desktop.Tests open System open System.IO open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop /// P65 职业切片五:货郎「打听行情」(OfferTrade 意图)读取既有 Kernel `World.Rumors` 池, /// 产出中文行情短答。全部为纯判定 + 文案,不新增状态、不改 `Sim.fs` 数值路径。 /// 红先用例:`Occupation.marketInquiryText` / `freshReliableRumors` / `inquiryRumor` 尚未实现。 [] type P65RumorInquiryTests () = let readyNpc (npc: Npc) : Npc = { npc with Mind = { npc.Mind with Action = Wander Target = actionTarget Wander ActionAge = minActionTicks EffectDone = true } } let rumor (id: int64) (tick: int64) (depth: int) (strength: float32) : RumorEvent = { Id = RumorId id Tick = tick DayIndex = tick / ticksPerDay OriginTick = tick Source = NpcId 0 Narrator = NpcId 0 Receiver = NpcId 1 Parent = None Depth = depth Strength = strength } let worldWithRumors (tick: int64) (rumors: RumorEvent list) : World = let baseWorld = initialWorldN 42UL 4 { baseWorld with Tick = tick Time = float tick * dtSeconds Npcs = baseWorld.Npcs |> Array.map readyNpc Rumors = rumors RumorCount = rumors.Length RumorOldestDay = rumors |> List.fold (fun acc entry -> min acc entry.DayIndex) Int64.MaxValue } let avatarAtNpc (world: World) : World = { world with Avatar = { world.Avatar with Pos = world.Npcs.[0].Pos } } let peddlerAnswer (world: World) : string option = Occupation.marketInquiryText (Some Occupation.Peddler) OfferTrade world [] member _.PeddlerOfferTradeReadsExistingRumorPoolAndAnswersInChinese () = let tick = ticksPerDay let world = worldWithRumors tick [ rumor 0L tick 0 1.0f ] Assert.AreEqual(1, Occupation.freshReliableRumors world |> List.length) Assert.IsTrue(Occupation.inquiryRumor world |> Option.isSome, "应命中一条新鲜可用谣言") match peddlerAnswer world with | None -> Assert.Fail("货郎提议交易应产出行情短答") | Some text -> Assert.IsTrue(text.Length > 0, "行情短答不得为空") Assert.IsTrue( text |> Seq.forall CjkGlyphAtlas.contains, sprintf "行情短答含缺字:%s" text) // 穷举全部地点/货品(id 索引 mod 6)与涨落分档(强度阈值)组合,断言无缺字。 let strengths = [ 1.0f; 0.6f; 0.3f; 0.13f ] for id in 0L .. 5L do for depth in 0 .. 1 do for strength in strengths do match peddlerAnswer (worldWithRumors tick [ rumor id tick depth strength ]) with | None -> Assert.Fail(sprintf "id=%d depth=%d strength=%.1f 应有短答" id depth strength) | Some answer -> Assert.IsTrue( answer |> Seq.forall CjkGlyphAtlas.contains, sprintf "行情短答含缺字:%s" answer) [] member _.SameInquiryInputProducesIdenticalText () = let tick = ticksPerDay let first = peddlerAnswer (worldWithRumors tick [ rumor 3L tick 1 0.6f ]) let second = peddlerAnswer (worldWithRumors tick [ rumor 3L tick 1 0.6f ]) let repeated = peddlerAnswer (worldWithRumors tick [ rumor 3L tick 1 0.6f ]) Assert.IsTrue(first.IsSome) Assert.AreEqual(first, second) Assert.AreEqual(first, repeated) [] member _.InquiryIgnoresStaleOrWeakOrMissingRumors () = let tick = ticksPerDay * 10L let stale = worldWithRumors tick [ rumor 0L (tick - rumorFreshnessTicks - 1L) 0 1.0f ] let weak = worldWithRumors tick [ rumor 0L tick 0 0.01f ] let empty = worldWithRumors tick [] for world in [ stale; weak; empty ] do Assert.AreEqual(None, peddlerAnswer world) [] member _.OnlyPeddlerOfferTradeProducesInquiry () = let tick = ticksPerDay let world = worldWithRumors tick [ rumor 0L tick 0 1.0f ] Assert.AreEqual(None, Occupation.marketInquiryText (Some Occupation.Farmer) OfferTrade world) Assert.AreEqual(None, Occupation.marketInquiryText (Some Occupation.Fisher) OfferTrade world) Assert.AreEqual(None, Occupation.marketInquiryText (Some Occupation.Scholar) OfferTrade world) Assert.AreEqual(None, Occupation.marketInquiryText None OfferTrade world) Assert.AreEqual(None, Occupation.marketInquiryText (Some Occupation.Peddler) SmallTalk world) Assert.AreEqual(None, Occupation.marketInquiryText (Some Occupation.Peddler) AskHelp world) [] member _.NonPeddlerDialogueStatusStaysByteIdenticalWhilePeddlerShowsInquiry () = let tick = ticksPerDay let baseWorld = worldWithRumors tick [ rumor 0L tick 0 1.0f ] |> avatarAtNpc let openAndAsk (task: Occupation.State option) = let view = { M5Interaction.initial with Task = task; Seed = 42UL } let afterInteract, opened = M5Interaction.apply M5Command.Interact baseWorld view Assert.AreEqual(DialoguePanel, opened.Panel) let _, answered = M5Interaction.apply M5Command.Intent3 afterInteract opened answered // 无职业 / 非货郎:状态逐字节等于既有 OfficeTrade 文案(旧行为回归)。 Assert.AreEqual( "dialogue intent=OfferTrade response=Bargaining", (openAndAsk None).Status) Assert.AreEqual( "dialogue intent=OfferTrade response=Bargaining", (openAndAsk (Some(Occupation.stateOf Occupation.Farmer))).Status) // 货郎:状态改为 inquiry|<中文行情短答>,并原样透出到情境状态条。 let peddler = openAndAsk (Some(Occupation.stateOf Occupation.Peddler)) Assert.IsTrue(peddler.Status.StartsWith("inquiry|"), peddler.Status) let answer = peddler.Status.Substring("inquiry|".Length) Assert.IsTrue(answer.Length > 0) Assert.IsTrue(answer |> Seq.forall CjkGlyphAtlas.contains, answer) Assert.AreEqual(answer, M5Interaction.statusText peddler) [] member _.SaveV2HeaderUnchangedAndV3RoundTripStable () = let tick = ticksPerDay let world = worldWithRumors tick [ rumor 0L tick 0 1.0f ] let v2 = WorldSave.save world Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2")) Assert.AreEqual(v2, WorldSave.saveWith None world) let peddler = Occupation.stateOf Occupation.Peddler let v3 = WorldSave.saveWith (Some peddler) world Assert.IsTrue(v3.StartsWith("LV_WORLD_SAVE_V3")) let path = Path.Combine(Path.GetTempPath(), sprintf "lv-p65-%s.sav" (Guid.NewGuid().ToString("N"))) try WorldSave.saveToFileWith (Some peddler) path world match WorldSave.loadFromFileWith path with | Ok (_, Some loaded) -> Assert.AreEqual("peddler", Occupation.saveToken loaded.Profile.Kind) Assert.AreEqual(peddler.Backpack.Length, loaded.Backpack.Length) | other -> Assert.Fail(sprintf "v3 round-trip failed: %A" other) finally if File.Exists path then File.Delete path