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/P31StoryUiTests.fs | 105 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 81 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 6 |
4 files changed, 191 insertions, 2 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index a0c98b0..d4fa1f5 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -10,6 +10,7 @@ <Compile Include="M6aTests.fs" /> <Compile Include="M6bTests.fs" /> <Compile Include="P16UiTests.fs" /> + <Compile Include="P31StoryUiTests.fs" /> <Compile Include="PrototypeTests.fs" /> <Compile Include="MapGenTests.fs" /> <Compile Include="PerformanceSummaryTests.fs" /> diff --git a/src/LivingVillage.Desktop.Tests/P31StoryUiTests.fs b/src/LivingVillage.Desktop.Tests/P31StoryUiTests.fs new file mode 100644 index 0000000..562794d --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P31StoryUiTests.fs @@ -0,0 +1,105 @@ +namespace LivingVillage.Desktop.Tests + +open System +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop + +module private P31Harness = + + let atNpc (id: int) (world: World) : World = + let npc = world.Npcs.[id] + { world with Avatar = { world.Avatar with Pos = npc.Pos } } + + let nearWorld () : World = Sim.initialWorldN 42UL 4 |> atNpc 0 + + let npcIndex (NpcId id) : int = id + +[<TestClass>] +type P31StoryUiTests () = + + // ---- 任务面板:剧情线名 + 进度段名 ---- + + [<TestMethod>] + member _.TaskPanelShowsTheStorylineNameAndStage () = + let world = Sim.initialWorldN 42UL 4 + let state = { Occupation.stateOf Occupation.Fisher with StoryStage = 2 } + let view = { M5Interaction.initial with Task = Some state } + let _, taskView = M5Interaction.apply ShowTaskPanel world view + let lines = M5Interaction.panelLines world taskView + Assert.IsTrue(lines |> List.contains "剧情线:水位与收成,走访中", $"lines={lines}") + + [<TestMethod>] + member _.TaskPanelShowsConcludedStageAndNoOccupationStaysStoryFree () = + let world = Sim.initialWorldN 42UL 4 + let concluded = { Occupation.stateOf Occupation.Scholar with StoryStage = 3 } + let view = { M5Interaction.initial with Task = Some concluded } + let _, taskView = M5Interaction.apply ShowTaskPanel world view + Assert.IsTrue( + M5Interaction.panelLines world taskView + |> List.contains "剧情线:庙前的争论,已收束") + let _, noneView = M5Interaction.apply ShowTaskPanel world M5Interaction.initial + Assert.IsFalse( + M5Interaction.panelLines world noneView + |> List.exists (fun line -> line.Contains("剧情线"))) + + // ---- 对话接线:回应偏差进记忆 + 剧情线推进 + 年鉴写入 ---- + + [<TestMethod>] + member _.DialogueThroughM5AppliesBiasAndAdvancesTheStory () = + let world = { P31Harness.nearWorld () with Tick = 5L * Sim.ticksPerDay } + let state = Occupation.stateOf Occupation.Fisher + let view = { M5Interaction.initial with Task = Some state; Seed = 42UL } + let personality = world.Npcs.[0].Mind.Personality + let openedWorld, openedView = M5Interaction.apply Interact world view + match openedView.Menu with + | None -> Assert.Fail("dialogue menu should open next to a villager") + | Some menu -> + let finalWorld, finalView = M5Interaction.apply Intent1 openedWorld openedView + // 剧情线推进一段(tick 第 5 日 ≥ 开线日) + let task = finalView.Task.Value + Assert.AreEqual<int>(1, task.StoryStage) + // 年鉴写入剧情条目 + Assert.IsTrue( + finalWorld.Annals + |> List.exists (fun entry -> + match entry.Kind with + | StoryAnnal -> entry.Summary.Contains("接了件事") + | _ -> false)) + // 回应偏差可观测:偏差后的回应与纯函数口径一致 + let biased = Occupation.biasResponse (Some Occupation.Fisher) SmallTalk personality + let unbiased = Sim.responseFor SmallTalk personality + if biased <> unbiased then + Assert.IsTrue( + finalWorld.Annals + |> List.exists (fun entry -> + match entry.Kind with + | DialogueAnnal outcome -> outcome.Response = biased + | _ -> false), + "the biased response must be the one recorded") + + [<TestMethod>] + member _.NoOccupationDialogueKeepsTheHistoricResponsePath () = + let world = { P31Harness.nearWorld () with Tick = 5L * Sim.ticksPerDay } + let view = M5Interaction.initial + let openedWorld, openedView = M5Interaction.apply Interact world view + match openedView.Menu with + | None -> Assert.Fail("dialogue menu should open next to a villager") + | Some menu -> + let personality = openedWorld.Npcs.[P31Harness.npcIndex menu.Target].Mind.Personality + let finalWorld, _ = M5Interaction.apply Intent1 openedWorld openedView + let expected = Sim.responseFor SmallTalk personality + Assert.IsTrue( + finalWorld.Npcs.[0].Mind.Memory + |> List.exists (fun memory -> + match memory.Kind with + | Dialogue (_, SmallTalk, response) -> response = expected + | _ -> false)) + // 无职业不产生剧情条目,剧情线状态不存在 + Assert.IsFalse( + finalWorld.Annals + |> List.exists (fun entry -> + match entry.Kind with + | StoryAnnal -> true + | _ -> false)) diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index ea5272b..c68cf6a 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -256,6 +256,23 @@ type LivingVillageGame() as this = let mutable splashFrame = 0L let mutable menuEntranceFrame = 0L let mutable menuShotFrame = 0L + // P31 证据钩子:LV_STORY_SHOT=1 时以指定职业开局、经真实对话管线推进剧情线后 + // 打开任务面板截图,拍完即退出。LV_STORY_OCCUPATION=farmer|fisher|peddler|scholar, + // LV_STORY_STAGE=0..3(默认 2),输出到 LV_RECORD_DIR/p31-story-<职业>-stage<N>.png。 + let storyShotMode = Environment.GetEnvironmentVariable("LV_STORY_SHOT") = "1" + let storyShotKind = + match Environment.GetEnvironmentVariable("LV_STORY_OCCUPATION") with + | "fisher" -> Occupation.Fisher + | "peddler" -> Occupation.Peddler + | "scholar" -> Occupation.Scholar + | _ -> Occupation.Farmer + let storyShotStage = + match Environment.GetEnvironmentVariable("LV_STORY_STAGE") with + | null | "" -> 2 + | v -> match Int32.TryParse v with (true, value) -> max 0 (min 3 value) | _ -> 2 + let mutable storyShotPrepared = false + let mutable storyShotFrame = 0L + let mutable storyShotSaved = false let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 @@ -750,6 +767,44 @@ type LivingVillageGame() as this = if not (isNull pixel) then pixel.Dispose() if not (isNull spriteBatch) then spriteBatch.Dispose() + /// P31 证据:以真实开局函数建世界,用真实对话管线(bias + onDialogue)推进剧情线, + /// 再打开任务面板——与玩家操作走同一套 Kernel 纯函数,仅脚本化输入。 + member private this.RunStoryShotSetup () = + let seed = 42UL + let kind = storyShotKind + let stage = storyShotStage + let lineStartDay = Occupation.Story.startDayOf seed kind + int64 (max 0 (stage - 1)) + let startTick = lineStartDay * Sim.ticksPerDay + 12L * Sim.ticksPerDay / 24L + let state = WorldBootstrap.occupationStateFor seed kind startTick + let world0 = WorldBootstrap.initialWorldWithOccupation false seed Sim.npcCount (Some state) + let world1 = { world0 with Tick = startTick; Time = float startTick * Sim.dtSeconds } + let world2 = + Sim.appendAnnal + { Tick = startTick; Kind = StoryAnnal; Summary = Occupation.Story.identitySummaryOf kind } + world1 + let mutable w = world2 + let mutable task = state + for i in 0 .. stage - 1 do + let npc = w.Npcs.[i % w.Npcs.Length] + let wNear = { w with Avatar = { w.Avatar with Pos = npc.Pos } } + let respond = Occupation.biasResponse (Some kind) + match Sim.chooseDialogueWith respond npc.Id SmallTalk wNear with + | DialogueSucceeded (_, next) -> + let nextState, summary = Occupation.Story.onDialogue seed task next + w <- + match summary with + | Some text -> Sim.appendAnnal { Tick = next.Tick; Kind = StoryAnnal; Summary = text } next + | None -> next + task <- nextState + | DialogueRejected (failure, _) -> printfn "story-shot dialogue rejected: %A" failure + world <- w + m5View <- { M5Interaction.initial with Task = Some task; Seed = seed } + let _, view = M5Interaction.apply ShowTaskPanel world m5View + m5View <- view + menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame + printfn "story-shot=prepared occupation=%s stage=%d tick=%d annals=%d" + (Occupation.saveToken kind) stage world.Tick world.Annals.Length + override this.Update(gameTime: GameTime) = let kb = Keyboard.GetState() let pressed (key: Keys) = kb.IsKeyDown(key) && not (prevKb.IsKeyDown(key)) @@ -762,7 +817,10 @@ type LivingVillageGame() as this = menuEntranceFrame <- 0L printfn (if skip then "splash=skip frame=%d" else "splash=done frame=%d") splashFrame else - if autoplayFlow then + if storyShotMode && not storyShotPrepared then + storyShotPrepared <- true + this.RunStoryShotSetup() + elif autoplayFlow then this.RunAutoplayFlow() elif menu.Page = Playing then this.UpdatePlaying gameTime kb pressed pressedAny @@ -1127,6 +1185,18 @@ type LivingVillageGame() as this = this.SaveBackBuffer(sprintf "%s/%s%04d.png" recordDirectory recordName recordIndex) if menuShotMode then this.CaptureMenuShot() + if storyShotMode && storyShotPrepared then + storyShotFrame <- storyShotFrame + 1L + if not storyShotSaved && storyShotFrame = 20L then + storyShotSaved <- true + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer( + sprintf "%s/p31-story-%s-stage%d.png" + recordDirectory + (Occupation.saveToken storyShotKind) + storyShotStage) + printfn "story-shot=saved occupation=%s stage=%d" (Occupation.saveToken storyShotKind) storyShotStage + this.Exit() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 member private this.DrawSplash() = @@ -1274,6 +1344,15 @@ type LivingVillageGame() as this = MenuState.occupationOptions |> List.iteri (fun index occupation -> drawSelection index (MenuState.occupationLabel occupation) (contentY + index * lineHeight)) + // P-d:选中职业时提示其轻剧情线(仅最小展示,复用既有选择页机制)。 + (match MenuState.selectedOccupation menu with + | Some kind -> + drawText + innerX + (contentY + MenuState.occupationOptions.Length * lineHeight) + mutedColor + (sprintf "剧情线:%s" (Occupation.Story.lineNameOf kind)) + | None -> ()) drawText innerX (panelY + panelHeight - 40) mutedColor "UP DOWN 选择 ENTER 确认 ESC 返回" | Settings -> drawText innerX headingY mutedColor (ChineseText.menuHeading SettingsHeading) diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs index bfe26c8..9aceff6 100644 --- a/src/LivingVillage.Desktop/Interaction.fs +++ b/src/LivingVillage.Desktop/Interaction.fs @@ -632,4 +632,8 @@ module M5Interaction = | None -> [ "今日暂无任务" ] | Some task -> [ sprintf "%s:%s" (Occupation.taskNameOf task.TemplateId) (taskStateText task.State) ] - Occupation.nameOf occupationState.Profile.Kind :: taskLines @ [ status ] + let storyLine = + sprintf "剧情线:%s,%s" + (Occupation.Story.lineNameOf occupationState.Profile.Kind) + (Occupation.Story.stageNameOf occupationState.StoryStage) + Occupation.nameOf occupationState.Profile.Kind :: taskLines @ [ storyLine ] @ [ status ] |
