diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs | 223 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 90 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 4 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/PlayerTrade.fs | 28 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/TaskRuntime.fs | 36 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/WorldBootstrap.fs | 10 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 88 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/WorldSave.fs | 18 |
9 files changed, 454 insertions, 44 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index ade22be..a3c1e45 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -32,6 +32,7 @@ <Compile Include="P52ProfessionTradeTests.fs" /> <Compile Include="P53InteractionPromptTests.fs" /> <Compile Include="P54ProfessionDailyTests.fs" /> + <Compile Include="P55ProfessionRewardTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs b/src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs new file mode 100644 index 0000000..353f728 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P55ProfessionRewardTests.fs @@ -0,0 +1,223 @@ +namespace LivingVillage.Desktop.Tests + +open System +open System.IO +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop + +/// P55 职业切片四:当日任务经济闭环——设计口径报酬入账 + 连续完成天数。 +/// +/// 报酬只发设计文档已写明的**完成结果物品**(`docs/design/occupation-system-design.md` +/// §3 任务表:夜捕 得 Fish×2;送粮 交给村民 Food×1),其余模板无写明物品 → 不发、只留 +/// 既有年鉴/关系反馈;禁止发明金额或新物品。连续天数按日历日累计,v3 持久化。 +[<TestClass>] +type P55ProfessionRewardTests () = + + let taskOn day template taskState : Occupation.DailyTask = + { TemplateId = template + TargetNpc = None + TargetTile = None + OfferedTick = day * Sim.ticksPerDay + DueTick = day * Sim.ticksPerDay + Sim.ticksPerDay - 1L + State = taskState } + + let fisherNightCatch taskState : Occupation.State = + { Occupation.stateOf Occupation.Fisher with Today = Some(taskOn 0L Occupation.NightCatch taskState) } + + let farmerDeliverGrain taskState : Occupation.State = + { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.DeliverGrain taskState) } + + let farmerHelpWork taskState : Occupation.State = + { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.HelpWork taskState) } + + let tradeEvent buyer seller item quantity : InteractionEvent = + { Tick = 10L; Kind = TradeEvent(buyer, seller, item, quantity, 10.0f) } + + let dialogueEvent actor target : InteractionEvent = + { Tick = 10L + Kind = + DialogueEvent + { Tick = 10L + Actor = actor + Target = target + Intent = SmallTalk + Response = Friendly + Valence = 0.1f } } + + let signalFor (template: Occupation.TaskTemplateId) : Occupation.TaskSignal = + match template with + | Occupation.NightCatch -> Occupation.NightAtWater + | Occupation.ObserveNotes -> Occupation.Observed + | Occupation.TillSoil -> Occupation.ArrivedAt(2, 2) + | Occupation.DeliverGrain -> Occupation.Traded(playerId, Food, 1) + | Occupation.SellFish -> Occupation.Traded(playerId, Fish, 1) + | Occupation.Resell -> Occupation.Traded(playerId, Spice, 1) + | Occupation.BuyGoods -> Occupation.Purchased(Food, 1) + | Occupation.HelpWork + | Occupation.MarketInquiry + | Occupation.ReasonDebate -> Occupation.Dialogued playerId + + let quantity (item: ItemKind) (state: Occupation.State) = Occupation.backpackQuantity item state + + [<TestMethod>] + member _.RewardTableOnlyCarriesDesignWrittenItems () = + Assert.AreEqual<(ItemKind * int) option>(Some(Fish, 2), Occupation.rewardOf Occupation.NightCatch) + Assert.AreEqual<(ItemKind * int) option>(Some(Food, 1), Occupation.rewardOf Occupation.DeliverGrain) + for template in + [ Occupation.HelpWork; Occupation.TillSoil; Occupation.SellFish; Occupation.MarketInquiry + Occupation.BuyGoods; Occupation.Resell; Occupation.ObserveNotes; Occupation.ReasonDebate ] do + Assert.AreEqual<(ItemKind * int) option>(None, Occupation.rewardOf template, sprintf "%A 无写明物品" template) + + [<TestMethod>] + member _.NightCatchCompletionGrantsFishAndBumpsStreak () = + let before = fisherNightCatch Occupation.Active + let after, reward = Occupation.applySignalToState Occupation.NightAtWater before + Assert.AreEqual<(ItemKind * int) option>(Some(Fish, 2), reward) + Assert.AreEqual<int>(quantity Fish before + 2, quantity Fish after) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, after.Today |> Option.map (fun t -> t.State)) + Assert.AreEqual<int>(1, after.Streak) + + [<TestMethod>] + member _.DeliverGrainCompletionGrantsFoodAndBumpsStreak () = + let before = farmerDeliverGrain Occupation.Active + let sold, reward = Occupation.advanceTaskWithReward (tradeEvent (NpcId 1) playerId Food 2) before + Assert.AreEqual<(ItemKind * int) option>(Some(Food, 1), reward) + Assert.AreEqual<int>(quantity Food before + 1, quantity Food sold) + Assert.AreEqual<int>(1, sold.Streak) + + [<TestMethod>] + member _.UnwrittenRewardStillCountsStreakWithoutItems () = + let before = farmerHelpWork Occupation.Active + let after, reward = Occupation.advanceTaskWithReward (dialogueEvent playerId (NpcId 1)) before + Assert.AreEqual<(ItemKind * int) option>(None, reward) + Assert.AreEqual<int>(before.Backpack |> List.sumBy (fun (_, n) -> n), after.Backpack |> List.sumBy (fun (_, n) -> n)) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, after.Today |> Option.map (fun t -> t.State)) + Assert.AreEqual<int>(1, after.Streak) + + [<TestMethod>] + member _.CompletionNeedsTheMatchingSignalAndOfferedDoesNotPay () = + // 不相干信号:不动背包、不加 streak。 + let active = fisherNightCatch Occupation.Active + let stalled, noReward = Occupation.applySignalToState (Occupation.ArrivedAt(1, 1)) active + Assert.AreEqual<(ItemKind * int) option>(None, noReward) + Assert.AreEqual<int>(0, stalled.Streak) + Assert.AreEqual<int>(quantity Fish active, quantity Fish stalled) + // Offered 未接受:完成信号不结算。 + let offered = fisherNightCatch Occupation.Offered + let stillOffered, offeredReward = Occupation.applySignalToState Occupation.NightAtWater offered + Assert.AreEqual<(ItemKind * int) option>(None, offeredReward) + Assert.AreEqual<int>(0, stillOffered.Streak) + + [<TestMethod>] + member _.TaskRuntimeSurfacesRewardInShortChineseFeedback () = + let after, message = TaskRuntime.advanceWithSignal Occupation.NightAtWater (fisherNightCatch Occupation.Active) + Assert.AreEqual<string option>(Some "任务完成:夜捕 +鱼×2", message) + Assert.AreEqual<int>(1, after.Streak) + let _, deliverMessage = TaskRuntime.advanceWithEvent (tradeEvent (NpcId 1) playerId Food 1) (farmerDeliverGrain Occupation.Active) + Assert.AreEqual<string option>(Some "任务完成:送粮 +食物×1", deliverMessage) + let _, helpMessage = TaskRuntime.advanceWithEvent (dialogueEvent playerId (NpcId 1)) (farmerHelpWork Occupation.Active) + Assert.AreEqual<string option>(Some "任务完成:帮工", helpMessage) + + [<TestMethod>] + member _.SameDayRefreshKeepsStreakAndCrossDayCarriesOnlyAfterCompletion () = + // 同日刷新:不动 streak。 + let completed = { fisherNightCatch Occupation.Done with Streak = 1 } + let sameDay = Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher 10L completed + Assert.AreEqual<int>(1, sameDay.Streak) + // 跨日且上一日已完成 → 保留 streak。 + let nextDayTick = Sim.ticksPerDay + 10L + let nextDay = Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher nextDayTick completed + Assert.AreEqual<int>(1, nextDay.Streak) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Offered, nextDay.Today |> Option.map (fun t -> t.State)) + // 跨日且上一日未完成 → 清零。 + let missed = { fisherNightCatch Occupation.Active with Streak = 3 } + let afterMiss = Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher nextDayTick missed + Assert.AreEqual<int>(0, afterMiss.Streak) + + [<TestMethod>] + member _.StreakAccumulatesAcrossConsecutiveCompletedDays () = + let mutable state = Occupation.stateOf Occupation.Fisher + for day in 0L .. 2L do + let tick = day * Sim.ticksPerDay + 10L + state <- Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher tick state + state <- { state with Today = state.Today |> Option.map Occupation.acceptTask } + let signal = signalFor state.Today.Value.TemplateId + let next, _ = Occupation.applySignalToState signal state + state <- next + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, state.Today |> Option.map (fun t -> t.State)) + Assert.AreEqual<int>(int day + 1, state.Streak) + + [<TestMethod>] + member _.SkippedDayResetsStreakBackToZero () = + let mutable state = Occupation.stateOf Occupation.Fisher + // 第 0、1 天连续完成。 + for day in 0L .. 1L do + let tick = day * Sim.ticksPerDay + 10L + state <- Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher tick state + state <- { state with Today = state.Today |> Option.map Occupation.acceptTask } + state <- fst (Occupation.applySignalToState (signalFor state.Today.Value.TemplateId) state) + Assert.AreEqual<int>(2, state.Streak) + // 第 2 天不做 → 进入第 3 天刷新时归零。 + state <- Occupation.refreshState 42UL (Occupation.occupationSeedOf Occupation.Fisher) Occupation.Fisher (3L * Sim.ticksPerDay + 10L) state + Assert.AreEqual<int>(0, state.Streak) + + [<TestMethod>] + member _.WorldBootstrapRefreshKeepsCompletedStreakOnNewDay () = + let state = { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.HelpWork Occupation.Done); Streak = 2 } + let refreshed = WorldBootstrap.refreshOccupationToday 42UL state (Sim.ticksPerDay + 5L) + Assert.AreEqual<int>(2, refreshed.Streak) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Offered, refreshed.Today |> Option.map (fun t -> t.State)) + + [<TestMethod>] + member _.V3RoundTripPersistsStreakAndRewardedBackpack () = + let world = Sim.initialWorldN 42UL 4 + let state = + { Occupation.stateOf Occupation.Fisher with + Today = Some(taskOn 0L Occupation.NightCatch Occupation.Done) + Streak = 4 + Backpack = [ Food, 8; Fish, 5 ] } + let path = Path.Combine(Path.GetTempPath(), "living-village-p55-" + Guid.NewGuid().ToString("N") + ".save") + try + WorldSave.saveToFileWith (Some state) path world + match WorldSave.loadFromFileWith path with + | Ok (_, Some restored) -> + Assert.AreEqual<int>(4, restored.Streak) + Assert.AreEqual<Occupation.TaskState option>(Some Occupation.Done, restored.Today |> Option.map (fun t -> t.State)) + Assert.AreEqual<int>(5, quantity Fish restored) + // 再次保存与首次逐字节一致(round-trip 稳定)。 + let text = File.ReadAllText path + Assert.AreEqual<string>(text, WorldSave.saveWith (Some restored) world) + | Ok (_, None) -> Assert.Fail("v3 必须读回职业侧车") + | Error message -> Assert.Fail(message) + finally + if File.Exists path then File.Delete path + + [<TestMethod>] + member _.V3WithoutStreakLoadsAsZeroAndSaveNoneStaysV2 () = + let world = Sim.initialWorldN 42UL 4 + let state = { Occupation.stateOf Occupation.Farmer with Today = Some(taskOn 0L Occupation.HelpWork Occupation.Done) } + let withFile = Path.Combine(Path.GetTempPath(), "living-village-p55-" + Guid.NewGuid().ToString("N") + ".save") + try + WorldSave.saveToFileWith (Some state) withFile world + match WorldSave.loadFromFileWith withFile with + | Ok (_, Some restored) -> Assert.AreEqual<int>(0, restored.Streak) + | Ok (_, None) -> Assert.Fail("v3 必须读回职业侧车") + | Error message -> Assert.Fail(message) + finally + if File.Exists withFile then File.Delete withFile + // 无职业存档仍是 v2 且与 saveWith None 逐字节一致。 + let v2 = WorldSave.save world + Assert.IsTrue(v2.StartsWith("LV_WORLD_SAVE_V2")) + Assert.AreEqual<string>(v2, WorldSave.saveWith None world) + match WorldSave.load v2 with + | Ok restored -> Assert.AreEqual<string>(v2, WorldSave.save restored) + | Error message -> Assert.Fail(message) + + [<TestMethod>] + member _.ObserveCompletionHasNoRewardSuffix () = + let state = { Occupation.stateOf Occupation.Scholar with Today = Some(taskOn 0L Occupation.ObserveNotes Occupation.Active) } + let after, message = TaskRuntime.advanceWithSignal Occupation.Observed state + Assert.AreEqual<string option>(Some "任务完成:观察记事", message) + Assert.AreEqual<int>(1, after.Streak) diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 284c246..8162351 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -373,6 +373,13 @@ type LivingVillageGame() as this = let mutable p54Step = 0 let mutable p54PendingName = "" let mutable p54Pending = false + // P55 证据钩子:LV_P55_SHOT=1 时以农夫真实开局,跳到第 3 日(当日任务=送粮), + // 走「面板(待接受) → 接受 → 真实卖出 Food(送粮完成,设计口径 +食物×1)→ 面板(已完成)」, + // 打印 streak/背包/反馈,供 analyze 脚本核验报酬入账与连续天数。 + let p55ShotMode = Environment.GetEnvironmentVariable("LV_P55_SHOT") = "1" + let mutable p55Step = 0 + let mutable p55PendingName = "" + let mutable p55Pending = false let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 @@ -1307,6 +1314,77 @@ type LivingVillageGame() as this = p54PendingName taskText (M5Interaction.panelName m5View) m5View.Status bag p54Step <- p54Step + 1 + /// P55 证据:跳到第 3 日(送粮)→ 面板(待接受) → 接受 → 真实卖出 Food(完成 +食物×1)。 + member private this.PrepareP55Shot () = + let setDay3Noon () = + let tick = 3L * Sim.ticksPerDay + M6Presentation.resolveStartTick (Some 12.0) 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 p55Pending then + match p55Step with + | 0 -> + setDay3Noon () + // 用真实跨日刷新把当日任务推进到第 3 日(seed 42/农夫 = 送粮)。 + match m5View.Task with + | Some state -> m5View <- { m5View with Task = Some(WorldBootstrap.refreshOccupationToday 42UL state world.Tick) } + | None -> () + let npc = world.Npcs.[0] + world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } } + ask M5Command.ShowTaskPanel + this.CenterCamera() + p55PendingName <- "p55-task-offered" + p55Pending <- true + | 1 -> + ask M5Command.AcceptTask + p55PendingName <- "p55-task-active" + p55Pending <- true + | 2 -> + ask M5Command.ClosePanel + ask M5Command.Interact + ask M5Command.SellToTarget + p55PendingName <- "p55-reward" + p55Pending <- true + | 3 -> + ask M5Command.ShowTaskPanel + p55PendingName <- "p55-task-done" + p55Pending <- true + | _ -> + printfn "p55-shot=done" + this.Exit() + + /// P55 证据:落盘并打印模板/状态/streak/背包/反馈。 + member private this.CaptureP55Shot () = + if p55Pending then + p55Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p55PendingName) + let stateText (state: Occupation.TaskState) = + match state with + | Occupation.Offered -> "offered" + | Occupation.Active -> "active" + | Occupation.Done -> "done" + | Occupation.Failed -> "failed" + let templateText, taskState, streak = + match m5View.Task with + | None -> "none", "none", 0 + | Some state -> + (match state.Today with + | Some task -> Occupation.taskNameOf task.TemplateId, stateText task.State, state.Streak + | None -> "none", "none", state.Streak) + let bag = + match m5View.Task with + | None -> "-" + | Some state -> + state.Backpack + |> List.map (fun (item, quantity) -> sprintf "%A=%d" item quantity) + |> String.concat ";" + printfn "p55-shot=%s template=%s state=%s streak=%d panel=%s status=%s bag=[%s]" + p55PendingName templateText taskState streak (M5Interaction.panelName m5View) m5View.Status bag + p55Step <- p55Step + 1 + /// P53 证据:真实录制提示出现/消失序列(远处 → 靠近 → 互动 → 关闭+离开)。 member private this.PrepareP53Recording () = let setHour (hour: float) = @@ -1844,6 +1922,16 @@ type LivingVillageGame() as this = else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP54Shot() + elif p55ShotMode 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.Farmer) + if menu.Selected <> targetIndex then this.DispatchMenuInput Down + else this.DispatchMenuInput Confirm + elif menu.Page = Playing then + this.PrepareP55Shot() elif menu.Page = Playing then this.UpdatePlaying gameTime kb pressed pressedAny elif menuShotMode then @@ -2263,6 +2351,8 @@ type LivingVillageGame() as this = this.CaptureP53Recording() if p54ShotMode && menu.Page = Playing then this.CaptureP54Shot() + if p55ShotMode && menu.Page = Playing then + this.CaptureP55Shot() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 member private this.DrawSplash() = diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs index 68f90d7..75575dd 100644 --- a/src/LivingVillage.Desktop/Interaction.fs +++ b/src/LivingVillage.Desktop/Interaction.fs @@ -442,8 +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 + // P54/P55:交易完成当日任务时把(含奖励的)短中文反馈一并带出。 + let taskMessage = TaskRuntime.completedMessageWithReward view.Task outcome.Occupation outcome.Reward outcome.World, { view with Panel = WorldPanel diff --git a/src/LivingVillage.Desktop/PlayerTrade.fs b/src/LivingVillage.Desktop/PlayerTrade.fs index 540445d..407536c 100644 --- a/src/LivingVillage.Desktop/PlayerTrade.fs +++ b/src/LivingVillage.Desktop/PlayerTrade.fs @@ -23,15 +23,12 @@ module PlayerTrade = Succeeded: bool /// 成交时的职业偏置后单位价(失败为 None)。 UnitPrice: float32 option + /// P55:本次交易若完成当日任务,设计口径发放的物品奖励。 + Reward: (ItemKind * int) option /// 面向 HUD 的短中文反馈。 Message: string } - let private itemName (item: ItemKind) : string = - match item with - | Food -> "食物" - | Fish -> "鱼" - | Spice -> "香料" - | Scroll -> "卷轴" + let private itemName (item: ItemKind) : string = Occupation.itemNameOf item let private quantityOf (item: ItemKind) (inventory: Map<ItemKind, int>) : int = inventory |> Map.tryFind item |> Option.defaultValue 0 @@ -112,12 +109,13 @@ module PlayerTrade = | _ -> 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 + /// P54/P55:交易事件同时推进当日任务(Offered 须先接受;判定走 Kernel 纯函数), + /// 并把任务的物品奖励一并返回。 + let private advanceOccupation (event: InteractionEvent) (occupation: Occupation.State) : Occupation.State * (ItemKind * int) option = + Occupation.advanceTaskWithReward event occupation let private failure (state: Occupation.State option) (world: World) (message: string) : Outcome = - { World = world; Occupation = state; Succeeded = false; UnitPrice = None; Message = message } + { World = world; Occupation = state; Succeeded = false; UnitPrice = None; Reward = None; Message = message } /// 玩家向 `counterparty` 买入 `quantity` 件 `item`。 let buy @@ -163,13 +161,14 @@ module PlayerTrade = |> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } }) |> replaceNpc updatedSeller |> recordTrade tradeEvent - let nextOccupation = - { occupation with Backpack = changeBackpack item quantity occupation.Backpack } + let nextOccupation, reward = + ({ occupation with Backpack = changeBackpack item quantity occupation.Backpack }) |> advanceOccupation tradeEvent { World = nextWorld Occupation = Some nextOccupation Succeeded = true UnitPrice = Some unitPrice + Reward = reward Message = sprintf "买入%s ×%d,单价 %.2f" (itemName item) quantity unitPrice } /// 玩家向 `counterparty` 卖出 `quantity` 件 `item`(须在背包内)。 @@ -216,13 +215,14 @@ module PlayerTrade = |> (fun w -> { w with Avatar = { w.Avatar with Mind = avatarMind } }) |> replaceNpc updatedBuyer |> recordTrade tradeEvent - let nextOccupation = - { occupation with Backpack = changeBackpack item -quantity occupation.Backpack } + let nextOccupation, reward = + ({ occupation with Backpack = changeBackpack item -quantity occupation.Backpack }) |> advanceOccupation tradeEvent { World = nextWorld Occupation = Some nextOccupation Succeeded = true UnitPrice = Some unitPrice + Reward = reward Message = sprintf "卖出%s ×%d,单价 %.2f" (itemName item) quantity unitPrice } /// HUD 入口:向 `counterparty` 买 1 件食物。 diff --git a/src/LivingVillage.Desktop/TaskRuntime.fs b/src/LivingVillage.Desktop/TaskRuntime.fs index 75675aa..efc57f3 100644 --- a/src/LivingVillage.Desktop/TaskRuntime.fs +++ b/src/LivingVillage.Desktop/TaskRuntime.fs @@ -9,10 +9,21 @@ open LivingVillage.Kernel /// (其内部是 `satisfiesCompletion`),绝不按日历索引硬编码计数。 module TaskRuntime = - let private completionMessage (before: Occupation.DailyTask option) (after: Occupation.DailyTask option) : string option = + /// 完成短中文反馈:有设计口径物品奖励时带「+物品×数量」。 + let completionText (task: Occupation.DailyTask) (reward: (Sim.ItemKind * int) option) : string = + let headline = sprintf "任务完成:%s" (Occupation.taskNameOf task.TemplateId) + match reward with + | Some(item, quantity) -> sprintf "%s +%s×%d" headline (Occupation.itemNameOf item) quantity + | None -> headline + + let private completionMessage + (before: Occupation.DailyTask option) + (after: Occupation.DailyTask option) + (reward: (Sim.ItemKind * int) 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)) + Some(completionText current reward) | _ -> None /// 接受当日任务(Offered → Active);无任务 / 已在进行 / 终态时无变化、无反馈。 @@ -23,19 +34,17 @@ module TaskRuntime = Some(sprintf "已接受任务:%s" (Occupation.taskNameOf task.TemplateId)) | _ -> state, None - /// 用一条既有世界事件推进当日任务;Active → Done 时返回短中文反馈。 + /// 用一条既有世界事件推进当日任务;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 next, reward = Occupation.advanceTaskWithReward event state + next, completionMessage before next.Today reward /// 用一条已折叠的任务信号(到达瓦片 / 观察 / 夜在水边)推进当日任务。 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 before = state.Today + let next, reward = Occupation.applySignalToState signal state + next, completionMessage before next.Today reward /// 折叠多条信号(后面的不覆盖已得到的完成反馈)。 let foldSignals (signals: Occupation.TaskSignal list) (state: Occupation.State) : Occupation.State * string option = @@ -58,7 +67,12 @@ module TaskRuntime = if isNight && nearWater then yield Occupation.NightAtWater ] /// 比较两份职业状态,返回本次推进是否让当日任务完成(供交易等路径复用)。 - let completedMessage (before: Occupation.State option) (after: Occupation.State option) : string option = + let completedMessageWithReward + (before: Occupation.State option) + (after: Occupation.State option) + (reward: (Sim.ItemKind * int) option) + : string option = completionMessage (before |> Option.bind (fun state -> state.Today)) (after |> Option.bind (fun state -> state.Today)) + reward diff --git a/src/LivingVillage.Desktop/WorldBootstrap.fs b/src/LivingVillage.Desktop/WorldBootstrap.fs index 7be24a7..e75855f 100644 --- a/src/LivingVillage.Desktop/WorldBootstrap.fs +++ b/src/LivingVillage.Desktop/WorldBootstrap.fs @@ -25,15 +25,9 @@ module WorldBootstrap = Today = Occupation.dailyTaskOf seed (Occupation.occupationSeedOf kind) (Occupation.dayIndexOf nowTick) kind } /// 跨日刷新当日任务;同日(含 Done/Failed 终态)原样保留。 + /// P55:经 Kernel `refreshState`,跨日时按上一日是否已完成保留/清零连续天数。 let refreshOccupationToday (seed: uint64) (state: Occupation.State) (nowTick: int64) : Occupation.State = - { state with - Today = - Occupation.refreshToday - seed - (Occupation.occupationSeedOf state.Profile.Kind) - state.Profile.Kind - nowTick - state.Today } + Occupation.refreshState seed (Occupation.occupationSeedOf state.Profile.Kind) state.Profile.Kind nowTick state /// 开局身份年鉴(design §3):带职业建世界后写入一条 `StoryAnnal`「以…为生」, /// 形成个人传记线。Game 与测试共用同一口径。 diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs index 45ef428..66ed4f2 100644 --- a/src/LivingVillage.Kernel/Occupation.fs +++ b/src/LivingVillage.Kernel/Occupation.fs @@ -201,19 +201,75 @@ module Occupation = TaskToken: int Today: DailyTask option StoryStage: int - Backpack: (Sim.ItemKind * int) list } + Backpack: (Sim.ItemKind * int) list + /// 连续完成天数(P55):当日任务 Done 时 +1;跨日刷新时若上一日已完成则保留,否则清零。 + Streak: int } let stateOf (kind: Kind) : State = { Profile = profileOf kind TaskToken = 0 Today = None StoryStage = 0 - Backpack = (profileOf kind).InitialInventory } + Backpack = (profileOf kind).InitialInventory + Streak = 0 } /// 背包内某物品数量(纯函数;无则 0)。 let backpackQuantity (item: Sim.ItemKind) (state: State) : int = state.Backpack |> List.sumBy (fun (kind, quantity) -> if kind = item then quantity else 0) + /// 物品中文名(HUD 短反馈共用;与 PlayerTrade 口径一致)。 + let itemNameOf (item: Sim.ItemKind) : string = + match item with + | Sim.Food -> "食物" + | Sim.Fish -> "鱼" + | Sim.Spice -> "香料" + | Sim.Scroll -> "卷轴" + + /// 背包发放物品:同物品合并数量,新物品追加尾部(保持声明顺序,稳定序列化)。 + let grantItem (item: Sim.ItemKind) (quantity: int) (backpack: (Sim.ItemKind * int) list) : (Sim.ItemKind * int) list = + if quantity <= 0 then + backpack + else + let mutable found = false + let updated = + backpack + |> List.map (fun (kind, current) -> + if kind = item then + found <- true + kind, current + quantity + else + kind, current) + if found then updated else updated @ [ item, quantity ] + + // ---- P55 经济闭环:设计口径报酬 + 连续完成天数 ---- + // 报酬 = `docs/design/occupation-system-design.md` §3 任务表已写明的**完成结果物品** + // (仅两处有物品+数量:夜捕「得 Fish×2」、送粮「交给村民 Food×1」);其余模板无写明物品 + // → 不发、只保留既有年鉴/关系反馈。禁止发明金额或新物品。 + + /// 完成任务的设计口径物品奖励(无写明 → None)。 + let rewardOf (template: TaskTemplateId) : (Sim.ItemKind * int) option = + match template with + | NightCatch -> Some(Sim.Fish, 2) + | DeliverGrain -> Some(Sim.Food, 1) + | _ -> None + + /// 对职业状态施加任务信号:Active 命中完成 → Done + Streak+1 + 发放设计写明物品。 + /// 返回(新状态, 本次发放的物品奖励 option)。 + let applySignalToState (signal: TaskSignal) (state: State) : State * (Sim.ItemKind * int) option = + match state.Today with + | Some task when task.State = Active && satisfiesCompletion task signal -> + let reward = rewardOf task.TemplateId + let backpack = + match reward with + | Some(item, quantity) -> grantItem item quantity state.Backpack + | None -> state.Backpack + { state with + Today = Some { task with State = Done } + Streak = state.Streak + 1 + Backpack = backpack }, + reward + | _ -> state, None + // ---- 既有事件通道 → 任务信号桥(design §3:完成判定统一走 InteractionEvent)---- /// 仅玩家发起的对话/交易才产生任务信号;NPC 之间互动的副作用不派任务。 @@ -225,12 +281,34 @@ module Occupation = | Sim.TradeEvent (buyer, _, item, quantity, _) when buyer = Sim.playerId -> Some(Purchased(item, quantity)) | _ -> None + /// 用一条既有事件推进当日任务并返回物品奖励(Offered 须先 acceptTask;终态不可复活)。 + /// 无职业 / 无当日任务 / 非玩家事件时原样返回。 + let advanceTaskWithReward (event: Sim.InteractionEvent) (state: State) : State * (Sim.ItemKind * int) option = + match signalOfInteraction event with + | Some signal -> applySignalToState signal state + | None -> state, None + /// 用一条既有事件推进当日任务(Offered 须先 acceptTask;终态不可复活)。 /// 无职业 / 无当日任务 / 非玩家事件时原样返回。 let advanceTask (event: Sim.InteractionEvent) (state: State) : State = - match state.Today, signalOfInteraction event with - | Some task, Some signal -> { state with Today = Some(applySignal signal task) } - | _ -> state + fst (advanceTaskWithReward event state) + + /// 跨日刷新职业状态(P55):重算当日任务,并按「上一日是否已完成」保留/清零连续天数。 + /// 同日刷新原样保留 Streak(含 Done 终态),避免每帧刷新把 streak 冲掉。 + let refreshState (seed: uint64) (occupationSeed: uint64) (kind: Kind) (nowTick: int64) (state: State) : State = + let today = refreshToday seed occupationSeed kind nowTick state.Today + let sameDay = + match state.Today with + | Some task -> dayIndexOf task.OfferedTick = dayIndexOf nowTick + | None -> false + let todayDay = dayIndexOf nowTick + let continuing = + match state.Today with + | Some task -> + // 同日刷新保留;跨日时仅当上一日「紧邻且已完成」才保留(跳过多日 = 漏完成 → 清零)。 + sameDay || (task.State = Done && dayIndexOf task.OfferedTick + 1L = todayDay) + | None -> false + { state with Today = today; Streak = (if continuing then state.Streak else 0) } // ---- 报价偏置(design §2/§3):Kernel 纯函数旁路,不改 Sim.fs 数值口径 ---- // 4 位定点整数基点(1/10000)表达偏置,避免浮点漂移;结果钳在 [0.95, 1.05]。 diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs index 3e11810..29c8d18 100644 --- a/src/LivingVillage.Kernel/WorldSave.fs +++ b/src/LivingVillage.Kernel/WorldSave.fs @@ -617,6 +617,11 @@ module WorldSave = | Some task -> writeDailyTask tokens task | None -> () writeBackpack tokens state.Backpack + // P55:连续完成天数仅在 > 0 时写入尾段,旧 v3(无此段)读回默认 0, + // 且缺省(无 streak)存档的文本布局与 P51/P52 完全一致。 + if state.Streak > 0 then + add tokens "streak" + addInt tokens state.Streak | None -> () String.Join("|", tokens) @@ -660,7 +665,8 @@ module WorldSave = TaskToken = task Today = None StoryStage = stage - Backpack = (Occupation.profileOf kind).InitialInventory }) + Backpack = (Occupation.profileOf kind).InitialInventory + Streak = 0 }) | _ -> () let tick = readInt64 reader "tick" let time = readFloat reader "time" @@ -679,20 +685,24 @@ module WorldSave = let tail = let mutable task : Occupation.DailyTask option = None let mutable backpack : (ItemKind * int) list option = None + let mutable streak = 0 while reader.Remaining > 0 do match reader.Take("occupation.tail") with | "task-today" -> task <- Some(readDailyTask reader) | "backpack" -> backpack <- Some(readBackpack reader) + | "streak" -> streak <- readInt reader "occupation.streak" | _ -> invalid "trailing save data" - task, backpack + task, backpack, streak + let tailTask, tailBackpack, tailStreak = tail if reader.Remaining <> 0 then invalid "trailing save data" let occupation = occupationOption |> Option.map (fun state -> { state with - Today = fst tail - Backpack = snd tail |> Option.defaultValue state.Backpack }) + Today = tailTask + Backpack = tailBackpack |> Option.defaultValue state.Backpack + Streak = tailStreak }) Ok ({ Tick = tick Time = time |
