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/P31StoryUiTests.fs105
-rw-r--r--src/LivingVillage.Desktop/Game.fs81
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs6
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 ]