1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
|
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` 尚未实现。
[<TestClass>]
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
[<TestMethod>]
member _.PeddlerOfferTradeReadsExistingRumorPoolAndAnswersInChinese () =
let tick = ticksPerDay
let world = worldWithRumors tick [ rumor 0L tick 0 1.0f ]
Assert.AreEqual<int>(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)
[<TestMethod>]
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<string option>(first, second)
Assert.AreEqual<string option>(first, repeated)
[<TestMethod>]
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<string option>(None, peddlerAnswer world)
[<TestMethod>]
member _.OnlyPeddlerOfferTradeProducesInquiry () =
let tick = ticksPerDay
let world = worldWithRumors tick [ rumor 0L tick 0 1.0f ]
Assert.AreEqual<string option>(None, Occupation.marketInquiryText (Some Occupation.Farmer) OfferTrade world)
Assert.AreEqual<string option>(None, Occupation.marketInquiryText (Some Occupation.Fisher) OfferTrade world)
Assert.AreEqual<string option>(None, Occupation.marketInquiryText (Some Occupation.Scholar) OfferTrade world)
Assert.AreEqual<string option>(None, Occupation.marketInquiryText None OfferTrade world)
Assert.AreEqual<string option>(None, Occupation.marketInquiryText (Some Occupation.Peddler) SmallTalk world)
Assert.AreEqual<string option>(None, Occupation.marketInquiryText (Some Occupation.Peddler) AskHelp world)
[<TestMethod>]
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<M5Panel>(DialoguePanel, opened.Panel)
let _, answered = M5Interaction.apply M5Command.Intent3 afterInteract opened
answered
// 无职业 / 非货郎:状态逐字节等于既有 OfficeTrade 文案(旧行为回归)。
Assert.AreEqual<string>(
"dialogue intent=OfferTrade response=Bargaining",
(openAndAsk None).Status)
Assert.AreEqual<string>(
"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<string>(answer, M5Interaction.statusText peddler)
[<TestMethod>]
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<string>(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<string>("peddler", Occupation.saveToken loaded.Profile.Kind)
Assert.AreEqual<int>(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
|