diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 23:55:20 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 23:55:20 +0800 |
| commit | 3669d9710e3783c325385a5648766b798d20ec77 (patch) | |
| tree | 5fc618616b4a2eab252ea3bb1c32dd5b1f9ddc36 /src | |
| parent | 6ea696b31ea342e43b61cf16aa1b3378a2bc68a3 (diff) | |
| download | living-village-3669d9710e3783c325385a5648766b798d20ec77.tar.gz | |
p54: 当日任务玩家可见与可完成——Kernel 状态机接入真实事件/环境信号(接受/对话/交易/到达/夜水边),HUD 短中文反馈 + 任务面板接受提示 + v3 任务态往返与 save(None) 兼容 + LV_P54_SHOT 证据
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P54ProfessionDailyTests.fs | 217 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 145 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 51 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/PlayerTrade.fs | 43 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/TaskRuntime.fs | 64 |
7 files changed, 501 insertions, 21 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index bda9932..ade22be 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -31,6 +31,7 @@ <Compile Include="P51ProfessionSliceTests.fs" /> <Compile Include="P52ProfessionTradeTests.fs" /> <Compile Include="P53InteractionPromptTests.fs" /> + <Compile Include="P54ProfessionDailyTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P54ProfessionDailyTests.fs b/src/LivingVillage.Desktop.Tests/P54ProfessionDailyTests.fs new file mode 100644 index 0000000..5d30921 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P54ProfessionDailyTests.fs @@ -0,0 +1,217 @@ +namespace LivingVillage.Desktop.Tests + +open System +open System.IO +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop + +/// P54 职业切片三:当日任务 → 玩家可见与可完成。 +/// 完成度只由**真实世界事件/信号**驱动(`Occupation.applySignal`),不接受任何 +/// 按日历索引硬编码的计数;Offered 须先接受为 Active。 +[<TestClass>] +type P54ProfessionDailyTests () = + + let mkTask template taskState : Occupation.DailyTask = + { TemplateId = template + TargetNpc = None + TargetTile = None + OfferedTick = 0L + DueTick = Sim.ticksPerDay - 1L + State = taskState } + + let stateWith template taskState : Occupation.State = + { Occupation.stateOf Occupation.Farmer with Today = Some(mkTask template taskState) } + + let stateWithout : Occupation.State = + { Occupation.stateOf Occupation.Farmer with Today = None } + + let dialogueEvent actor target : InteractionEvent = + { Tick = 10L + Kind = + DialogueEvent + { Tick = 10L + Actor = actor + Target = target + Intent = SmallTalk + Response = Friendly + Valence = 0.1f } } + + let tradeEvent buyer seller item quantity : InteractionEvent = + { Tick = 10L + Kind = TradeEvent(buyer, seller, item, quantity, 10.0f) } + + let taskStateOf (state: Occupation.State) = + state.Today |> Option.map (fun task -> task.State) + + [<TestMethod>] + member _.AcceptOfferedTaskActivatesItWithFeedback () = + let activated, message = TaskRuntime.accept (stateWith Occupation.HelpWork Occupation.Offered) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf activated) + Assert.AreEqual<string option>(Some "已接受任务:帮工", message) + // 已在进行/终态:无变化、无反馈。 + let active, activeMessage = TaskRuntime.accept (stateWith Occupation.HelpWork Occupation.Active) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf active) + Assert.AreEqual<string option>(None, activeMessage) + let none, noneMessage = TaskRuntime.accept stateWithout + Assert.AreEqual<Occupation.TaskState option>(None, taskStateOf none) + Assert.AreEqual<string option>(None, noneMessage) + + [<TestMethod>] + member _.DialogueEventsCompleteActiveDialogueTasks () = + let complete template = + let state = stateWith template Occupation.Active + let next, message = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 3)) state + Assert.IsTrue((taskStateOf next = Some Occupation.Done), sprintf "%A 应完成" template) + Assert.IsTrue(message.IsSome, sprintf "%A 完成应有短反馈" template) + message.Value + for template in [ Occupation.HelpWork; Occupation.MarketInquiry; Occupation.ReasonDebate ] do + let message = complete template + Assert.AreEqual<string>(sprintf "任务完成:%s" (Occupation.taskNameOf template), message) + + [<TestMethod>] + member _.TradeEventsCompleteActiveTradeTasks () = + let state = stateWith Occupation.Resell Occupation.Active + let sold, soldMessage = TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) playerId Spice 1) state + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf sold) + Assert.AreEqual<string option>(Some "任务完成:转卖", soldMessage) + + let grain = stateWith Occupation.DeliverGrain Occupation.Active + Assert.AreEqual<Occupation.TaskState option>( + Some Occupation.Done, + taskStateOf (fst (TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) playerId Food 2) grain))) + let fish = stateWith Occupation.SellFish Occupation.Active + Assert.AreEqual<Occupation.TaskState option>( + Some Occupation.Done, + taskStateOf (fst (TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) playerId Fish 1) fish))) + let buy = stateWith Occupation.BuyGoods Occupation.Active + Assert.AreEqual<Occupation.TaskState option>( + Some Occupation.Done, + taskStateOf (fst (TaskRuntime.advanceWithEvent (tradeEvent playerId (NpcId 1) Food 1) buy))) + + [<TestMethod>] + member _.EnvironmentSignalsOnlyFireOnRealChange () = + // 无基线不发到达信号(避免开局即完成翻土)。 + Assert.AreEqual<Occupation.TaskSignal list>([], TaskRuntime.environmentSignals false false None (5, 5)) + // 跨瓦片 → 到达瓦片。 + Assert.AreEqual<Occupation.TaskSignal list>( + [ Occupation.ArrivedAt(6, 5) ], + TaskRuntime.environmentSignals false false (Some(5, 5)) (6, 5)) + // 同瓦片不重复到达。 + Assert.AreEqual<Occupation.TaskSignal list>([], TaskRuntime.environmentSignals false false (Some(6, 5)) (6, 5)) + // 夜 + 水边 → 夜在水边。 + Assert.AreEqual<Occupation.TaskSignal list>( + [ Occupation.NightAtWater ], + TaskRuntime.environmentSignals true true (Some(6, 5)) (6, 5)) + + [<TestMethod>] + member _.FoldedEnvironmentSignalsCompleteTheirTasks () = + let arrived = stateWith Occupation.TillSoil Occupation.Active + Assert.AreEqual<Occupation.TaskState option>( + Some Occupation.Done, + taskStateOf (fst (TaskRuntime.advanceWithSignal (Occupation.ArrivedAt(52, 38)) arrived))) + let night = stateWith Occupation.NightCatch Occupation.Active + Assert.AreEqual<Occupation.TaskState option>( + Some Occupation.Done, + taskStateOf (fst (TaskRuntime.advanceWithSignal Occupation.NightAtWater night))) + let observe = stateWith Occupation.ObserveNotes Occupation.Active + Assert.AreEqual<Occupation.TaskState option>( + Some Occupation.Done, + taskStateOf (fst (TaskRuntime.advanceWithSignal Occupation.Observed observe))) + + [<TestMethod>] + member _.OfferedTaskNeedsAcceptBeforeItCanComplete () = + let offered = stateWith Occupation.HelpWork Occupation.Offered + let stillOffered, missed = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 4)) offered + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Offered, taskStateOf stillOffered) + Assert.AreEqual<string option>(None, missed) + + let active, _ = TaskRuntime.accept offered + let done', message = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 4)) active + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf done') + Assert.IsTrue(message.IsSome) + + [<TestMethod>] + member _.OnlyTheMatchingRealSignalCompletesAndNothingIsCalendarCounted () = + // 非玩家事件(NPC↔NPC)不得推进。 + let active = stateWith Occupation.HelpWork Occupation.Active + let npcOnly, _ = TaskRuntime.advanceWithEvent (dialogueEvent (NpcId 1) (NpcId 2)) active + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf npcOnly) + let npcTrade, _ = TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) (NpcId 2) Food 1) (stateWith Occupation.Resell Occupation.Active) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf npcTrade) + // 不匹配的信号不完成(观察任务不会被对话完成)。 + let observe = stateWith Occupation.ObserveNotes Occupation.Active + let afterDialogue, _ = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 1)) observe + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf afterDialogue) + // 反复推进也不改变 OfferedTick/DueTick(无日历计数副作用)。 + let before = (stateWith Occupation.HelpWork Occupation.Active).Today.Value + let after = (fst (TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 1)) active)).Today.Value + Assert.AreEqual<int64>(before.OfferedTick, after.OfferedTick) + Assert.AreEqual<int64>(before.DueTick, after.DueTick) + + [<TestMethod>] + member _.AcceptTaskCommandActivatesTheDailyTaskThroughTheUi () = + let world = Sim.initialWorldN 42UL 4 + let view = { M5Interaction.initial with Panel = TaskPanel; Task = Some(stateWith Occupation.HelpWork Occupation.Offered) } + let _, accepted = M5Interaction.apply AcceptTask world view + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Active, taskStateOf accepted.Task.Value) + Assert.AreEqual<string>("已接受任务:帮工", M5Interaction.statusText accepted) + + [<TestMethod>] + member _.ObserveCommandCompletesTheObserveNotesTask () = + let world = Sim.initialWorldN 42UL 4 + let view = { M5Interaction.initial with Task = Some(stateWith Occupation.ObserveNotes Occupation.Active) } + let _, observed = M5Interaction.apply Observe world view + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf observed.Task.Value) + Assert.AreEqual<string>("任务完成:观察记事", M5Interaction.statusText observed) + + [<TestMethod>] + member _.DialogueThroughTheMenuCompletesTheHelpWorkTask () = + let world = Sim.initialWorldN 42UL 4 + let npc = world.Npcs.[0] + let placed = { world with Avatar = { world.Avatar with Pos = npc.Pos } } + let seeded = { M5Interaction.initial with Task = Some(stateWith Occupation.HelpWork Occupation.Active) } + let _, opened = M5Interaction.apply M5Command.Interact placed seeded + Assert.AreEqual<M5Panel>(DialoguePanel, opened.Panel) + let _, replied = M5Interaction.apply Intent1 placed opened + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf replied.Task.Value) + Assert.IsTrue((M5Interaction.statusText replied).StartsWith("任务完成")) + + [<TestMethod>] + member _.PlayerTradeCompletesTheResellTask () = + let seed = 42UL + // 货郎背包含香料,才能卖出以完成「转卖」。 + let state = + { Occupation.stateOf Occupation.Peddler with + Today = Some(mkTask Occupation.Resell Occupation.Active) } + let world = WorldBootstrap.initialWorldWithOccupation false seed 4 (Some state) + let npc = world.Npcs.[0].Id + let sell = PlayerTrade.sell (Some state) npc Spice 1 world + Assert.IsTrue(sell.Succeeded) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf sell.Occupation.Value) + + [<TestMethod>] + member _.DailyTaskStatePersistsThroughV3RoundTripAndV2StaysCompatible () = + let world = Sim.initialWorldN 42UL 4 + let state = + { Occupation.stateOf Occupation.Peddler with + Today = Some(mkTask Occupation.Resell Occupation.Done) } + // v2(None)保持与旧体一致、无任务侧车。 + let v2 = WorldSave.save world + Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2")) + match WorldSave.load v2 with + | Ok restored -> Assert.AreEqual<string>(v2, WorldSave.save restored) + | Error message -> Assert.Fail(message) + // v3 round-trip:任务模板 + 终态读回一致。 + let path = Path.Combine(Path.GetTempPath(), "living-village-p54-" + Guid.NewGuid().ToString("N") + ".save") + try + WorldSave.saveToFileWith (Some state) path world + match WorldSave.loadFromFileWith path with + | Ok (_, Some restored) -> + Assert.AreEqual<Occupation.DailyTask option>(state.Today, restored.Today) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, taskStateOf restored) + | Ok (_, None) -> Assert.Fail("v3 must round-trip the occupation sidecar") + | Error message -> Assert.Fail(message) + finally + if File.Exists path then File.Delete path diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 0cf996c..284c246 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -252,6 +252,8 @@ type LivingVillageGame() as this = let mutable relationView = false let mutable relationSnapshot: float32[,] option = None let mutable m5View = M5Interaction.initial + // P54:记录上一帧玩家所在瓦片,用于折叠「到达瓦片」环境信号(仅在跨越瓦片时触发)。 + let mutable lastAvatarTile : (int * int) option = None let mutable simulationControl = startSimulationControl let mutable legacyStepsPerFrame = (if sampleMode then None else initialLegacyStepsPerFrame) let mutable menu = MenuState.create startSimulationControl false @@ -357,6 +359,20 @@ type LivingVillageGame() as this = let mutable p53Step = 0 let mutable p53PendingName = "" let mutable p53Pending = false + // P54 证据钩子:LV_P54_SHOT=1 时以农夫真实开局,走「任务面板(待接受) → 接受 → + // 环境到达 + 对话 + 卖出三条真实信号」全流程,拍「待接受 / 进行中 / 完成」帧; + // 每步打印任务模板与状态,供 analyze 脚本核验 Offered→Active→Done 只由真实信号驱动。 + // LV_P54_OCCUPATION=farmer|fisher|peddler|scholar(默认 farmer)。 + let p54ShotMode = Environment.GetEnvironmentVariable("LV_P54_SHOT") = "1" + let p54Occupation = + match Environment.GetEnvironmentVariable("LV_P54_OCCUPATION") with + | "fisher" -> Occupation.Fisher + | "peddler" -> Occupation.Peddler + | "scholar" -> Occupation.Scholar + | _ -> Occupation.Farmer + let mutable p54Step = 0 + let mutable p54PendingName = "" + let mutable p54Pending = false let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 @@ -1198,6 +1214,99 @@ type LivingVillageGame() as this = bag p52Step <- p52Step + 1 + /// P54 证据:农夫真实开局 → 任务面板(待接受) → 接受 → 环境到达 + 对话 + 买/卖 + /// 四条真实信号,验证 Offered→Active→Done 只由真实事件驱动。 + member private this.PrepareP54Shot () = + 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 + if not p54Pending then + match p54Step with + | 0 -> + setHour 12.0 + let npc = world.Npcs.[0] + world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } } + ask M5Command.ShowTaskPanel + this.CenterCamera() + p54PendingName <- "p54-task-offered" + p54Pending <- true + | 1 -> + ask M5Command.AcceptTask + p54PendingName <- "p54-task-active" + p54Pending <- true + | 2 -> + ask M5Command.ClosePanel + // 模拟跨入当前瓦片:以相邻瓦片为基线喂「到达瓦片」环境信号(真实折叠函数)。 + let tile = (int (world.Avatar.Pos.X / float32 Sim.tilePixels), int (world.Avatar.Pos.Y / float32 Sim.tilePixels)) + match m5View.Task with + | Some state -> + let previousTile = (fst tile + 1, snd tile) + let signals = TaskRuntime.environmentSignals false false (Some previousTile) tile + let advanced, message = TaskRuntime.foldSignals signals state + m5View <- { m5View with Task = Some advanced; Status = (match message with Some m -> "task|" + m | None -> m5View.Status) } + | None -> () + this.CenterCamera() + p54PendingName <- "p54-arrive" + p54Pending <- true + | 3 -> + ask M5Command.Interact + ask M5Command.Intent1 + p54PendingName <- "p54-dialogue" + p54Pending <- true + | 4 -> + ask M5Command.Interact + ask M5Command.BuyFromTarget + p54PendingName <- "p54-buy" + p54Pending <- true + | 5 -> + ask M5Command.Interact + ask M5Command.SellToTarget + p54PendingName <- "p54-sell" + p54Pending <- true + | 6 -> + ask M5Command.ShowTaskPanel + p54PendingName <- "p54-task-done" + p54Pending <- true + | _ -> + printfn "p54-shot=done" + this.Exit() + + /// P54 证据:在 `Draw` 末尾落盘并打印任务模板/状态/反馈/背包。 + member private this.CaptureP54Shot () = + if p54Pending then + p54Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p54PendingName) + let stateName (state: Occupation.TaskState) = + match state with + | Occupation.Offered -> "offered" + | Occupation.Active -> "active" + | Occupation.Done -> "done" + | Occupation.Failed -> "failed" + let taskText = + match m5View.Task with + | None -> "occupation=none template=none state=none" + | Some state -> + match state.Today with + | None -> "occupation=yes template=none state=none" + | Some task -> + sprintf "occupation=yes template=%s state=%s" + (Occupation.taskNameOf task.TemplateId) (stateName task.State) + let bag = + match m5View.Task with + | None -> "-" + | Some state -> + state.Backpack + |> List.map (fun (item, quantity) -> sprintf "%A=%d" item quantity) + |> String.concat ";" + printfn "p54-shot=%s %s panel=%s status=%s bag=[%s]" + p54PendingName taskText (M5Interaction.panelName m5View) m5View.Status bag + p54Step <- p54Step + 1 + /// P53 证据:真实录制提示出现/消失序列(远处 → 靠近 → 互动 → 关闭+离开)。 member private this.PrepareP53Recording () = let setHour (hour: float) = @@ -1439,6 +1548,7 @@ type LivingVillageGame() as this = elif pressed Keys.Tab then Some ToggleNeeds elif pressed Keys.C then Some ShowChronicle elif pressed Keys.T then Some ShowTaskPanel + elif m5View.Panel = TaskPanel && pressedAny [ Keys.Enter; Keys.Space ] then Some AcceptTask else None match m5Command with | Some command -> @@ -1493,6 +1603,29 @@ type LivingVillageGame() as this = if refreshed <> state then m5View <- { m5View with Task = Some refreshed } | None -> () + // P54:把真实世界环境折叠成任务信号 —— 跨瓦片触发「到达瓦片」; + // 夜里站在水边触发「夜在水边」。全部走 Kernel 纯函数,不按日历计数。 + match m5View.Task with + | Some state -> + let tileX = int (world.Avatar.Pos.X / float32 Sim.tilePixels) + let tileY = int (world.Avatar.Pos.Y / float32 Sim.tilePixels) + let isNight = (M6Presentation.profileAtTick world.Tick).Mode = Night + let nearWater = + match riverscapeMap with + | Some map -> + [ 1, 0; -1, 0; 0, 1; 0, -1 ] + |> List.exists (fun (dx, dy) -> SceneDetail.isWater map (tileX + dx) (tileY + dy)) + | None -> false + let signals = TaskRuntime.environmentSignals isNight nearWater lastAvatarTile (tileX, tileY) + lastAvatarTile <- Some(tileX, tileY) + let advanced, message = TaskRuntime.foldSignals signals state + if advanced <> state then + m5View <- + { m5View with + Task = Some advanced + Status = (match message with Some text -> "task|" + text | None -> m5View.Status) + StatusTick = (match message with Some _ -> Some world.Tick | None -> m5View.StatusTick) } + | None -> () m5View <- M5Interaction.refreshPrompt world m5View member private this.RunAutoplayFlow() = @@ -1701,6 +1834,16 @@ type LivingVillageGame() as this = else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP53Recording() + elif p54ShotMode then + if menu.Page = MainMenu then + this.DispatchMenuInput Confirm + elif menu.Page = OccupationSelect then + let targetIndex = + MenuState.occupationOptions |> List.findIndex (fun option -> option = Some p54Occupation) + if menu.Selected <> targetIndex then this.DispatchMenuInput Down + else this.DispatchMenuInput Confirm + elif menu.Page = Playing then + this.PrepareP54Shot() elif menu.Page = Playing then this.UpdatePlaying gameTime kb pressed pressedAny elif menuShotMode then @@ -2118,6 +2261,8 @@ type LivingVillageGame() as this = this.CaptureP52Shot() if p53RecordMode && menu.Page = Playing then this.CaptureP53Recording() + if p54ShotMode && menu.Page = Playing then + this.CaptureP54Shot() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 member private this.DrawSplash() = diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs index 48bc3e7..68f90d7 100644 --- a/src/LivingVillage.Desktop/Interaction.fs +++ b/src/LivingVillage.Desktop/Interaction.fs @@ -34,6 +34,7 @@ type M5Command = | Intent6 | BuyFromTarget | SellToTarget + | AcceptTask | Observe | ToggleNeeds | ShowChronicle @@ -333,6 +334,15 @@ module M5Interaction = | None -> next withAnnal, Some nextState | None -> next, None + // P54:玩家对话是真实事件(Actor=玩家),驱动对话类当日任务。 + let advanced, taskMessage = + match storyTask with + | Some taskState -> + let next, message = + TaskRuntime.advanceWithEvent + { Tick = storyWorld.Tick; Kind = DialogueEvent outcome } taskState + Some next, message + | None -> None, None storyWorld, { view with Panel = WorldPanel @@ -340,8 +350,11 @@ module M5Interaction = Chronicle = Sim.annalText storyWorld Prompt = None PromptTargetPos = None - Task = storyTask - Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response + Task = advanced + Status = + 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, @@ -365,6 +378,7 @@ module M5Interaction = | Intent6 | BuyFromTarget | SellToTarget -> view.Panel = DialoguePanel + | AcceptTask -> view.Panel = TaskPanel | Observe | ToggleNeeds | ShowChronicle @@ -428,6 +442,8 @@ module M5Interaction = let outcome = if command = BuyFromTarget then PlayerTrade.buyFood view.Task menu.Target world else PlayerTrade.sellFirst view.Task menu.Target world + // P54:交易完成当日任务时把短中文反馈一并带出。 + let taskMessage = TaskRuntime.completedMessage view.Task outcome.Occupation outcome.World, { view with Panel = WorldPanel @@ -435,17 +451,40 @@ module M5Interaction = Prompt = None PromptTargetPos = None Task = outcome.Occupation - Status = (if outcome.Succeeded then "trade ok|" else "trade fail|") + outcome.Message + Status = + (if outcome.Succeeded then "trade ok|" else "trade fail|") + + outcome.Message + + (match taskMessage with Some message -> "|" + message | None -> "") StatusTick = Some outcome.World.Tick } + | AcceptTask -> + match view.Task with + | None -> world, { view with Status = "task|无职业任务" } + | Some occupationState -> + let next, message = TaskRuntime.accept occupationState + world, + { view with + Task = Some next + Status = "task|" + (message |> Option.defaultValue "当前任务无需接受") } | Observe -> let observations = Sim.observeVisible (observationBounds world) world + // P54:「观察」是真实动作,驱动观察记事类任务。 + let task, taskMessage = + match view.Task with + | Some occupationState -> + let next, message = TaskRuntime.advanceWithSignal Occupation.Observed occupationState + Some next, message + | None -> None, None world, { view with Panel = ObservationPanel Observations = observations + Task = task Prompt = None PromptTargetPos = None - Status = sprintf "observation visible_npcs=%d" observations.Length } + Status = + match taskMessage with + | Some message -> "task|" + message + | None -> sprintf "observation visible_npcs=%d" observations.Length } | ToggleNeeds -> if view.Panel = NeedsPanel then world, refreshPrompt world { view with Panel = WorldPanel; Needs = None; Status = "needs panel closed" } @@ -545,6 +584,7 @@ module M5Interaction = | value when value.StartsWith("observation") -> "观察完成" | value when value.StartsWith("chronicle") -> "年鉴已打开" | "task panel open" -> "任务面板已打开" + | value when value.StartsWith("task|") -> value.Substring("task|".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") -> "当前没有可用对话" @@ -670,6 +710,9 @@ module M5Interaction = | None -> [ "今日暂无任务" ] | Some task -> [ sprintf "%s:%s" (Occupation.taskNameOf task.TemplateId) (taskStateText task.State) ] + @ (match task.State with + | Occupation.Offered -> [ "回车:接受任务" ] + | _ -> []) let storyLine = sprintf "剧情线:%s,%s" (Occupation.Story.lineNameOf occupationState.Profile.Kind) diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index b3243e2..ab0a906 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -10,6 +10,7 @@ <Compile Include="ProceduralMap.fs" /> <Compile Include="MapGen.fs" /> <Compile Include="WorldBootstrap.fs" /> + <Compile Include="TaskRuntime.fs" /> <Compile Include="PlayerTrade.fs" /> <Compile Include="CharacterArt.fs" /> <Compile Include="FloaterArt.fs" /> diff --git a/src/LivingVillage.Desktop/PlayerTrade.fs b/src/LivingVillage.Desktop/PlayerTrade.fs index e1ea53e..540445d 100644 --- a/src/LivingVillage.Desktop/PlayerTrade.fs +++ b/src/LivingVillage.Desktop/PlayerTrade.fs @@ -100,25 +100,22 @@ module PlayerTrade = let private replaceNpc (npc: Npc) (world: World) : World = { world with Npcs = world.Npcs |> Array.map (fun current -> if current.Id = npc.Id then npc else current) } - let private recordTrade - (buyer: NpcId) - (seller: NpcId) - (item: ItemKind) - (quantity: int) - (unitPrice: float32) - (world: World) - : World = - let event = - { Tick = world.Tick - Kind = TradeEvent(buyer, seller, item, quantity, unitPrice) } + let private recordTrade (event: InteractionEvent) (world: World) : World = let entry = { Tick = world.Tick Kind = TradeAnnal event Summary = - sprintf "trade tick=%d buyer=%A seller=%A item=%A quantity=%d price=%.2f" - world.Tick buyer seller item quantity unitPrice } + match event.Kind with + | TradeEvent(buyer, seller, item, quantity, unitPrice) -> + sprintf "trade tick=%d buyer=%A seller=%A item=%A quantity=%d price=%.2f" + world.Tick buyer seller item quantity unitPrice + | _ -> sprintf "trade tick=%d" world.Tick } Sim.appendAnnal entry { world with Events = world.Events @ [ event ] } + /// P54:交易事件同时推进当日任务(Offered 须先接受;判定走 Kernel 纯函数)。 + let private advanceOccupation (event: InteractionEvent) (occupation: Occupation.State) : Occupation.State = + Occupation.advanceTask event occupation + let private failure (state: Occupation.State option) (world: World) (message: string) : Outcome = { World = world; Occupation = state; Succeeded = false; UnitPrice = None; Message = message } @@ -158,13 +155,19 @@ module PlayerTrade = { seller.Mind with Needs = { seller.Mind.Needs with Money = seller.Mind.Needs.Money + total } Memory = recordMemory world.Tick (Sold(playerId, item, quantity, unitPrice)) seller.Mind.Memory } } + let tradeEvent = + { Tick = world.Tick + Kind = TradeEvent(playerId, counterparty, item, quantity, unitPrice) } let nextWorld = world |> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } }) |> replaceNpc updatedSeller - |> recordTrade playerId counterparty item quantity unitPrice + |> recordTrade tradeEvent + let nextOccupation = + { occupation with Backpack = changeBackpack item quantity occupation.Backpack } + |> advanceOccupation tradeEvent { World = nextWorld - Occupation = Some { occupation with Backpack = changeBackpack item quantity occupation.Backpack } + Occupation = Some nextOccupation Succeeded = true UnitPrice = Some unitPrice Message = sprintf "买入%s ×%d,单价 %.2f" (itemName item) quantity unitPrice } @@ -205,13 +208,19 @@ module PlayerTrade = { buyer.Mind with Needs = { buyer.Mind.Needs with Money = buyer.Mind.Needs.Money - total } Memory = recordMemory world.Tick (Bought(playerId, item, quantity, unitPrice)) buyer.Mind.Memory } } + let tradeEvent = + { Tick = world.Tick + Kind = TradeEvent(counterparty, playerId, item, quantity, unitPrice) } let nextWorld = world |> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } }) |> replaceNpc updatedBuyer - |> recordTrade counterparty playerId item quantity unitPrice + |> recordTrade tradeEvent + let nextOccupation = + { occupation with Backpack = changeBackpack item -quantity occupation.Backpack } + |> advanceOccupation tradeEvent { World = nextWorld - Occupation = Some { occupation with Backpack = changeBackpack item -quantity occupation.Backpack } + Occupation = Some nextOccupation Succeeded = true UnitPrice = Some unitPrice Message = sprintf "卖出%s ×%d,单价 %.2f" (itemName item) quantity unitPrice } diff --git a/src/LivingVillage.Desktop/TaskRuntime.fs b/src/LivingVillage.Desktop/TaskRuntime.fs new file mode 100644 index 0000000..75675aa --- /dev/null +++ b/src/LivingVillage.Desktop/TaskRuntime.fs @@ -0,0 +1,64 @@ +namespace LivingVillage.Desktop + +open LivingVillage.Kernel + +/// P54 职业切片三:把 Kernel 的当日任务状态机接到 Desktop 运行时(玩家可见 / 可完成)。 +/// +/// 全部为纯函数:接受任务、按**真实世界事件/信号**推进、给出短中文反馈。 +/// 完成判定只复用 Kernel 既有 `Occupation.advanceTask` / `applySignal` +/// (其内部是 `satisfiesCompletion`),绝不按日历索引硬编码计数。 +module TaskRuntime = + + let private completionMessage (before: Occupation.DailyTask option) (after: Occupation.DailyTask option) : string option = + match before, after with + | Some previous, Some current when previous.State = Occupation.Active && current.State = Occupation.Done -> + Some(sprintf "任务完成:%s" (Occupation.taskNameOf current.TemplateId)) + | _ -> None + + /// 接受当日任务(Offered → Active);无任务 / 已在进行 / 终态时无变化、无反馈。 + let accept (state: Occupation.State) : Occupation.State * string option = + match state.Today with + | Some task when task.State = Occupation.Offered -> + { state with Today = Some { task with State = Occupation.Active } }, + Some(sprintf "已接受任务:%s" (Occupation.taskNameOf task.TemplateId)) + | _ -> state, None + + /// 用一条既有世界事件推进当日任务;Active → Done 时返回短中文反馈。 + let advanceWithEvent (event: Sim.InteractionEvent) (state: Occupation.State) : Occupation.State * string option = + let before = state.Today + let next = Occupation.advanceTask event state + next, completionMessage before next.Today + + /// 用一条已折叠的任务信号(到达瓦片 / 观察 / 夜在水边)推进当日任务。 + let advanceWithSignal (signal: Occupation.TaskSignal) (state: Occupation.State) : Occupation.State * string option = + match state.Today with + | Some task -> + let after = Occupation.applySignal signal task + { state with Today = Some after }, completionMessage (Some task) (Some after) + | None -> state, None + + /// 折叠多条信号(后面的不覆盖已得到的完成反馈)。 + let foldSignals (signals: Occupation.TaskSignal list) (state: Occupation.State) : Occupation.State * string option = + signals + |> List.fold + (fun (current, found) signal -> + let next, message = advanceWithSignal signal current + next, (match found with Some _ -> found | None -> message)) + (state, None) + + /// 由真实世界环境折叠出的任务信号:跨瓦片 → 到达;夜 + 水边 → 夜在水边。 + /// `previousTile = None`(尚未建立基线)时不产生到达信号,避免开局即误判完成。 + let environmentSignals + (isNight: bool) + (nearWater: bool) + (previousTile: (int * int) option) + (tile: int * int) + : Occupation.TaskSignal list = + [ if previousTile.IsSome && previousTile.Value <> tile then yield Occupation.ArrivedAt tile + if isNight && nearWater then yield Occupation.NightAtWater ] + + /// 比较两份职业状态,返回本次推进是否让当日任务完成(供交易等路径复用)。 + let completedMessage (before: Occupation.State option) (after: Occupation.State option) : string option = + completionMessage + (before |> Option.bind (fun state -> state.Today)) + (after |> Option.bind (fun state -> state.Today)) |
