summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P65RumorInquiryTests.fs166
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs6
-rw-r--r--src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.pngbin85104 -> 89701 bytes
-rw-r--r--src/LivingVillage.Desktop/Assets/cjk-glyphs.txt17
-rw-r--r--src/LivingVillage.Desktop/CjkGlyphAtlas.fs2
-rw-r--r--src/LivingVillage.Desktop/Game.fs87
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs16
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs61
9 files changed, 349 insertions, 7 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 9f60cce..5d195f7 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -37,6 +37,7 @@
<Compile Include="P61GlyphCoverageTests.fs" />
<Compile Include="P62EvidenceTests.fs" />
<Compile Include="P64EvidenceTests.fs" />
+ <Compile Include="P65RumorInquiryTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P65RumorInquiryTests.fs b/src/LivingVillage.Desktop.Tests/P65RumorInquiryTests.fs
new file mode 100644
index 0000000..21ad47b
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P65RumorInquiryTests.fs
@@ -0,0 +1,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
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index da81f0e..a49cecd 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -1096,14 +1096,14 @@ type PrototypeTests () =
[<TestMethod>]
member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () =
- Assert.AreEqual<int>(323, characters.Length)
+ Assert.AreEqual<int>(340, characters.Length)
CollectionAssert.AreEqual(characters, characters |> Array.sort)
Assert.AreEqual<int>(characters.Length, characters |> Array.distinct |> Array.length)
Assert.AreEqual<int>(16, columns)
Assert.AreEqual<int>(24, cellPixels)
- Assert.AreEqual<int>(21, rowCount)
+ Assert.AreEqual<int>(22, rowCount)
Assert.AreEqual<int>(384, pixelWidth)
- Assert.AreEqual<int>(504, pixelHeight)
+ Assert.AreEqual<int>(528, pixelHeight)
Assert.AreEqual<string>("Assets/cjk-glyph-atlas.png", assetRelativePath)
Assert.AreEqual<string>("Assets/cjk-glyphs.txt", manifestRelativePath)
diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
index ed1bf37..69662c0 100644
--- a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
+++ b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
Binary files differ
diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
index 0191fc0..5c37717 100644
--- a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
+++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
@@ -4,6 +4,8 @@
不
与
业
+东
+两
中
为
主
@@ -16,6 +18,7 @@
事
于
互
+些
交
人
今
@@ -58,6 +61,7 @@
助
匀
包
+北
单
卖
南
@@ -67,6 +71,7 @@
友
取
受
+口
只
可
各
@@ -88,6 +93,7 @@
天
夫
失
+头
好
始
子
@@ -108,7 +114,10 @@
差
己
已
+巴
+巷
币
+布
帮
常
平
@@ -155,6 +164,7 @@
效
敌
数
+文
料
新
方
@@ -181,6 +191,7 @@
板
林
果
+柴
标
格
档
@@ -193,6 +204,7 @@
江
没
法
+涨
淡
清
渔
@@ -210,6 +222,7 @@
留
白
的
+盐
目
看
真
@@ -224,6 +237,7 @@
竹
笑
第
+米
粮
精
系
@@ -246,10 +260,13 @@
能
自
范
+茶
获
菜
+落
衅
行
+西
观
认
讨
diff --git a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
index 8b42596..ab03039 100644
--- a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
+++ b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
@@ -11,7 +11,7 @@ module CjkGlyphAtlas =
// Keep this table sorted and stable so the generated PNG has a reproducible layout.
let private characterTable =
- "×一上不与业中为主乐乡书买了争事于互交人今从以件价任众会传位体作使保信倍停先入关内册再农冬准几出分切到前剧力功务动助匀包单卖南卷原去友取受只可各同和品回围圃土在坏墙备夏多夜大天夫失好始子存完定家察对将小尚就层居屋工差己已币帮常平年庄库应庙度开式当录待得心必态怒总恼情愁意戏成或户打找报拒择指挑捕据接提播操收改效敌数料新方无日旧时明昏易春是晓晚晨景暂更最有未村束板林果标格档桥歉段民水求江没法淡清渔游热照物状玩理瓦生用田留白的目看真石确社离秋移稳空竹笑第粮精系紧纭纷线结给绝继绪续置翻耕聊职背能自范获菜衅行观认讨议记讲论设访试话询说请读谅谈败货贩资走足路跳车转轴过近返还进迫退送选速道邻郎都里量金鉴钱门闭问闯闲间闻附雨零需面页项须食饥饿香鱼黄黛,:"
+ "×一上不与业东两中为主乐乡书买了争事于互些交人今从以件价任众会传位体作使保信倍停先入关内册再农冬准几出分切到前剧力功务动助匀包北单卖南卷原去友取受口只可各同和品回围圃土在坏墙备夏多夜大天夫失头好始子存完定家察对将小尚就层居屋工差己已巴巷币布帮常平年庄库应庙度开式当录待得心必态怒总恼情愁意戏成或户打找报拒择指挑捕据接提播操收改效敌数文料新方无日旧时明昏易春是晓晚晨景暂更最有未村束板林果柴标格档桥歉段民水求江没法涨淡清渔游热照物状玩理瓦生用田留白的盐目看真石确社离秋移稳空竹笑第米粮精系紧纭纷线结给绝继绪续置翻耕聊职背能自范茶获菜落衅行西观认讨议记讲论设访试话询说请读谅谈败货贩资走足路跳车转轴过近返还进迫退送选速道邻郎都里量金鉴钱门闭问闯闲间闻附雨零需面页项须食饥饿香鱼黄黛,:"
let characters = characterTable.ToCharArray()
let rowCount = (characters.Length + columns - 1) / columns
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 6b52225..862e1de 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -337,6 +337,12 @@ type LivingVillageGame() as this =
let mutable p64PendingName = ""
let mutable p64Pending = false
let mutable p64MoveFrames = 0
+ // P65 证据钩子(职业切片五):货郎真实开局 → 与村民真实闲聊写入既有 World.Rumors →
+ // 真实 Interact 打开对话菜单(拍菜单帧)→ 选「提议交易」询价,拍带中文行情短答的帧。
+ let p65ShotMode = Environment.GetEnvironmentVariable("LV_P65_SHOT") = "1"
+ let mutable p65Step = 0
+ let mutable p65PendingName = ""
+ let mutable p65Pending = false
// P45 证据钩子:LV_P45_SHOT=1 时拍角色 idle / 行走朝下 / 行走朝右 三帧(真实游戏截图)。
let p45ShotMode = Environment.GetEnvironmentVariable("LV_P45_SHOT") = "1"
let mutable p45Step = 0
@@ -1158,6 +1164,75 @@ type LivingVillageGame() as this =
p64PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y m5View.HomeMode
p64Step <- p64Step + 1
+ /// P65 证据:货郎真实开局 → 闲聊写入既有谣言池 → 真实 Interact 开菜单 → 提议交易询价。
+ member private this.PrepareP65Shot () =
+ let setHour (hour: float) =
+ let tick = M6Presentation.resolveStartTick (Some hour) false
+ world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds }
+ let ask (command: M5Command) =
+ let nextWorld, nextView = M5Interaction.apply command world m5View
+ world <- nextWorld
+ m5View <- nextView
+ let placeAtNpc () =
+ let npc = world.Npcs.[0]
+ world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } }
+ if not p65Pending then
+ match p65Step with
+ | 0 ->
+ setHour 12.0
+ placeAtNpc ()
+ ask M5Command.Interact
+ this.CenterCamera()
+ p65PendingName <- "p65-menu"
+ p65Pending <- true
+ | 1 ->
+ // 真实闲聊一次:把玩家对话写入既有 World.Rumors(最新在前),供询价读取。
+ ask M5Command.Intent1
+ placeAtNpc ()
+ ask M5Command.Interact
+ // 非货郎回归:同世界跑农夫 Interact + Intent3,应仍得旧 dialogue 文案(无 inquiry|)。
+ let farmerView =
+ { M5Interaction.initial with
+ Task = Some(Occupation.stateOf Occupation.Farmer)
+ Seed = m5View.Seed }
+ let farmerWorld, farmerOpen = M5Interaction.apply M5Command.Interact world farmerView
+ let _, farmerAnswered = M5Interaction.apply M5Command.Intent3 farmerWorld farmerOpen
+ printfn "p65-regression status=%s" farmerAnswered.Status
+ // 同一世界两次查询:证明同输入逐字符串一致(禁随机)。
+ let answer1 = Occupation.marketInquiryText (Some Occupation.Peddler) Sim.OfferTrade world
+ let answer2 = Occupation.marketInquiryText (Some Occupation.Peddler) Sim.OfferTrade world
+ printfn
+ "p65-inquiry rumors=%d fresh=%d hit=%b text1=%s text2=%s equal=%b"
+ world.Rumors.Length
+ (Occupation.freshReliableRumors world |> List.length)
+ (Occupation.inquiryRumor world |> Option.isSome)
+ (answer1 |> Option.defaultValue "<none>")
+ (answer2 |> Option.defaultValue "<none>")
+ (answer1 = answer2)
+ p65Step <- p65Step + 1
+ | 2 ->
+ ask M5Command.Intent3
+ p65PendingName <- "p65-answer"
+ p65Pending <- true
+ | _ ->
+ printfn "p65-shot=done"
+ this.Exit()
+
+ /// P65 证据:落盘并打印帧名/面板/状态,供 analyze 核验「货郎 status=inquiry|...」。
+ member private this.CaptureP65Shot () =
+ if p65Pending then
+ p65Pending <- false
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p65PendingName)
+ printfn
+ "p65-shot=%s panel=%s avatar=(%.0f,%.0f) status=%s"
+ p65PendingName
+ (M5Interaction.panelName m5View)
+ world.Avatar.Pos.X
+ world.Avatar.Pos.Y
+ m5View.Status
+ p65Step <- p65Step + 1
+
member private this.PrepareP45Shot () =
let baseTick = M6Presentation.resolveStartTick (Some 12.0) false
let setTick (tick: int64) =
@@ -2163,6 +2238,16 @@ type LivingVillageGame() as this =
else this.DispatchMenuInput Confirm
elif menu.Page = Playing then
this.PrepareP64Shot()
+ elif p65ShotMode then
+ if menu.Page = MainMenu then
+ this.DispatchMenuInput Confirm
+ elif menu.Page = OccupationSelect then
+ let targetIndex =
+ MenuState.occupationOptions |> List.findIndex (fun option -> option = Some Occupation.Peddler)
+ if menu.Selected <> targetIndex then this.DispatchMenuInput Down
+ else this.DispatchMenuInput Confirm
+ elif menu.Page = Playing then
+ this.PrepareP65Shot()
elif p45ShotMode then
if menu.Page = MainMenu then
this.DispatchMenuInput Confirm
@@ -2639,6 +2724,8 @@ type LivingVillageGame() as this =
this.CaptureP62Shot()
if p64ShotMode && menu.Page = Playing then
this.CaptureP64Shot()
+ if p65ShotMode && menu.Page = Playing then
+ this.CaptureP65Shot()
if p45ShotMode && menu.Page = Playing then
this.CaptureP45Shot()
if p46ShotMode then
diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs
index 75575dd..ba792d1 100644
--- a/src/LivingVillage.Desktop/Interaction.fs
+++ b/src/LivingVillage.Desktop/Interaction.fs
@@ -318,6 +318,12 @@ module M5Interaction =
match view.Task with
| Some task -> Occupation.biasResponse (Some task.Profile.Kind)
| None -> Sim.responseFor
+ // P65:货郎打听行情读**对话前**既有谣言池,产出中文行情短答。
+ // 非货郎 / 无职业 / 无命中谣言 → None,落回既有 dialogue 文案(旧行为逐字节不变)。
+ let inquiry =
+ match view.Task with
+ | Some task -> Occupation.marketInquiryText (Some task.Profile.Kind) intent world
+ | None -> None
match Sim.chooseDialogueWith respond menu.Target intent world with
| DialogueSucceeded(outcome, next) ->
// P-d 剧情线:每次玩家对话至多推进一段,摘要写年鉴(StoryAnnal)。
@@ -352,9 +358,12 @@ module M5Interaction =
PromptTargetPos = None
Task = advanced
Status =
- match taskMessage with
- | Some message -> "task|" + message
- | None -> sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response
+ match inquiry with
+ | Some text -> "inquiry|" + text
+ | None ->
+ match taskMessage with
+ | Some message -> "task|" + message
+ | None -> sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response
StatusTick = Some storyWorld.Tick }
| DialogueRejected(failure, next) ->
next,
@@ -585,6 +594,7 @@ module M5Interaction =
| value when value.StartsWith("chronicle") -> "年鉴已打开"
| "task panel open" -> "任务面板已打开"
| value when value.StartsWith("task|") -> value.Substring("task|".Length)
+ | value when value.StartsWith("inquiry|") -> value.Substring("inquiry|".Length)
| value when value.StartsWith("trade ok|") -> value.Substring("trade ok|".Length)
| value when value.StartsWith("trade fail|") -> "交易失败:" + value.Substring("trade fail|".Length)
| value when value.StartsWith("no dialogue") -> "当前没有可用对话"
diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs
index 66ed4f2..cb9e25b 100644
--- a/src/LivingVillage.Kernel/Occupation.fs
+++ b/src/LivingVillage.Kernel/Occupation.fs
@@ -362,6 +362,67 @@ module Occupation =
| None -> baseResponse
| Some kind -> remapResponse kind intent baseResponse
+ // ---- P65 职业切片五:货郎「打听行情」(OfferTrade 意图)读既有 World.Rumors 池 ----
+ // 纯判定 + 文案:不新增任何状态、不动 Sim.fs 数值路径。行情短答只由既有谣言工作集
+ // (玩家对话已写入,最新在前)按同一套新鲜度/可信度口径派生;同一世界同一次查询可重现。
+
+ let private inquiryPlaces = [| "东头"; "西头"; "南头"; "北头"; "桥头"; "巷口" |]
+ let private inquiryGoods = [| "盐巴"; "米价"; "鱼价"; "布价"; "茶价"; "柴价" |]
+
+ /// 同一套口径(对齐 Sim.latestRumorFor):在新鲜窗口内、Tick 不晚于当前、
+ /// 且衰减后强度不低于门槛的谣言,才算「可打听的行情」。无接收者过滤(打听面向全村)。
+ let freshReliableRumors (world: Sim.World) : Sim.RumorEvent list =
+ world.Rumors
+ |> List.filter (fun rumor ->
+ world.Tick - rumor.Tick <= Sim.rumorFreshnessTicks
+ && rumor.Tick <= world.Tick
+ && Sim.rumorStrengthAt world.Tick rumor >= Sim.rumorMinimumStrength)
+
+ /// 打听命中的目标谣言:衰减强度最大者;平手取 Tick 更大者,再平手取 RumorId 更大者。
+ /// 全序比较保证同输入完全相同结果(无随机、无哈希遍历顺序依赖)。
+ let inquiryRumor (world: Sim.World) : Sim.RumorEvent option =
+ let stronger (candidate: Sim.RumorEvent) (best: Sim.RumorEvent) =
+ let candidateStrength = Sim.rumorStrengthAt world.Tick candidate
+ let bestStrength = Sim.rumorStrengthAt world.Tick best
+ if candidateStrength > bestStrength then true
+ elif candidateStrength < bestStrength then false
+ elif candidate.Tick <> best.Tick then candidate.Tick > best.Tick
+ else candidate.Id > best.Id
+
+ match freshReliableRumors world with
+ | [] -> None
+ | first :: rest ->
+ Some(rest |> List.fold (fun best candidate -> if stronger candidate best then candidate else best) first)
+
+ let private movementOf (strength: float32) : string =
+ if strength >= 0.75f then "涨了两文"
+ elif strength >= 0.5f then "涨了一文"
+ elif strength >= 0.25f then "还稳当"
+ else "有些回落"
+
+ /// 由命中谣言派生一条确定性行情短答:新鲜度前缀 + 地点 + 货品 + 涨落。
+ /// 地点/货品索引只由 (RumorId, Depth) 决定,涨落由衰减强度分档,全程整数/纯函数。
+ let private inquiryTextOf (world: Sim.World) (rumor: Sim.RumorEvent) : string =
+ let (Sim.RumorId id) = rumor.Id
+ let index = int ((id + int64 rumor.Depth) % 6L)
+ let place = inquiryPlaces.[index]
+ let good = inquiryGoods.[(index + 3) % 6]
+ let recency = if world.Tick - rumor.Tick < Sim.ticksPerDay then "今日" else "近日"
+ sprintf "%s%s的%s%s" recency place good (movementOf (Sim.rumorStrengthAt world.Tick rumor))
+
+ /// P65:货郎「提议交易」(= 打听行情/询价)时产出的行情短答。
+ /// 仅 `Some Peddler + OfferTrade + 命中可用谣言` 返回 Some;其余组合 None,
+ /// 让非货郎/无职业路径逐字节退回既有 `dialogue intent=...` 文案。
+ /// 与 `MarketInquiry` 当日任务共用同一「一次玩家对话事件」判定条件(见 satisfiesCompletion)。
+ let marketInquiryText
+ (kind: Kind option)
+ (intent: Sim.DialogueIntent)
+ (world: Sim.World)
+ : string option =
+ match kind, intent with
+ | Some Peddler, Sim.OfferTrade -> inquiryRumor world |> Option.map (inquiryTextOf world)
+ | _ -> None
+
// ---- 轻剧情线(design §5 / P-d):三段式 接任务 → 走访 → 收束 ----
// 纯年鉴/对话驱动:每次玩家对话至多推进一段,产出只有(StoryStage 推进 + 年鉴摘要文本),
// 由调用方写入世界。全部输入 = (seed, 世界 tick, 关系状态, 职业状态),纯函数、确定性。