summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/LivingVillage.Kernel.Tests/DailyTaskTests.fs109
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs20
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs151
3 files changed, 238 insertions, 42 deletions
diff --git a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
index 12bdf68..e784d83 100644
--- a/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
+++ b/src/LivingVillage.Kernel.Tests/DailyTaskTests.fs
@@ -19,6 +19,23 @@ module private DailyTaskHarness =
let accepted (template: Occupation.TaskTemplateId) : Occupation.DailyTask =
Occupation.acceptTask (taskOf template Occupation.Offered)
+ let worldWithAnnal () : World =
+ let world = Sim.initialWorldN 7UL 2
+ match Sim.trade { Buyer = NpcId 0; Seller = NpcId 1; Item = Food; Quantity = 1 } world with
+ | TradeSucceeded next -> next
+ | TradeRejected (failure, _) -> Assert.Fail($"trade rejected: {failure}"); Unchecked.defaultof<World>
+
+ let taskForDay (day: int64) : Occupation.DailyTask =
+ (Occupation.dailyTaskOf 42UL 7UL day Occupation.Fisher).Value |> Occupation.acceptTask
+
+ let withTempSave (action: string -> unit) =
+ let id = Guid.NewGuid().ToString("N")
+ let path = Path.Combine(Path.GetTempPath(), $"living-village-p11-{id}.save")
+ try
+ action path
+ finally
+ if File.Exists path then File.Delete path
+
[<TestClass>]
type DailyTaskTests () =
@@ -189,3 +206,95 @@ type DailyTaskTests () =
Assert.AreEqual<Occupation.TaskState>(Occupation.Failed, (Occupation.expireAt (offered.DueTick + 1L) offered).State)
let finished = { active with State = Occupation.Done }
Assert.AreEqual<Occupation.TaskState>(Occupation.Done, (Occupation.expireAt (finished.DueTick + 1L) finished).State)
+
+ // ---- §2-A 存档 v3:annals 之后追加 today task 尾段 ----
+
+ [<TestMethod>]
+ member _.SaveV3AppendsTaskTailAfterAnnalsAndRoundTrips () =
+ let world = DailyTaskHarness.worldWithAnnal ()
+ let state =
+ { (Occupation.stateOf Occupation.Fisher) with
+ TaskToken = 5
+ StoryStage = 2
+ Today = Some(DailyTaskHarness.taskForDay 2L) }
+ DailyTaskHarness.withTempSave (fun path ->
+ WorldSave.saveToFileWith (Some state) path world
+ let text = File.ReadAllText path
+ Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "v3 header expected")
+ let tokens = text.Split('|')
+ Assert.IsTrue(tokens |> Array.exists (fun token -> token = "task-today"), "task tail marker expected")
+ Assert.AreEqual<string>("tile-none", Array.last tokens)
+ let markerIndex = tokens |> Array.findIndex (fun token -> token = "task-today")
+ let lastAnnalKindIndex =
+ tokens |> Array.findIndexBack (fun token -> token = "trade")
+ Assert.IsTrue(markerIndex > lastAnnalKindIndex, "task tail must follow the annals segment")
+ match WorldSave.loadFromFileWith path with
+ | Ok (restored, Some restoredState) ->
+ Assert.AreEqual<Occupation.State>(state, restoredState)
+ Assert.AreEqual<string>(WorldSave.save world, WorldSave.save restored)
+ Assert.AreEqual<string>(text, WorldSave.saveWith (Some restoredState) restored)
+ | Ok (_, None) -> Assert.Fail("v3 with task tail must restore the occupation")
+ | Error message -> Assert.Fail($"v3 task round trip failed: {message}"))
+
+ [<TestMethod>]
+ member _.SaveV3WithoutTaskKeepsOldTailFreeLayout () =
+ let world = DailyTaskHarness.worldWithAnnal ()
+ let state = Occupation.stateOf Occupation.Peddler
+ DailyTaskHarness.withTempSave (fun path ->
+ WorldSave.saveToFileWith (Some state) path world
+ let text = File.ReadAllText path
+ Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "v3 header expected")
+ Assert.IsFalse(text.Contains("task-today"), "no-task v3 must not carry a task tail")
+ match WorldSave.loadFromFileWith path with
+ | Ok (restored, Some restoredState) ->
+ Assert.AreEqual<Occupation.DailyTask option>(None, restoredState.Today)
+ Assert.AreEqual<string>(text, WorldSave.saveWith (Some restoredState) restored)
+ | Ok (_, None) -> Assert.Fail("v3 with kind must restore the occupation")
+ | Error message -> Assert.Fail($"v3 no-task read failed: {message}"))
+
+ [<TestMethod>]
+ member _.V1AndV2SavesCarryNoTaskTail () =
+ let world = Sim.initialWorld 42UL
+ DailyTaskHarness.withTempSave (fun path ->
+ WorldSave.saveToFile path world
+ let v2Text = File.ReadAllText path
+ Assert.IsTrue(v2Text.StartsWith("LV_WORLD_SAVE_V2"), "default save must stay v2")
+ Assert.IsFalse(v2Text.Contains("task-today"))
+ match WorldSave.loadFromFileWith path with
+ | Ok (restored, occupation) ->
+ Assert.AreEqual<Occupation.State option>(None, occupation)
+ Assert.AreEqual<string>(v2Text, WorldSave.save restored)
+ | Error message -> Assert.Fail($"v2 read failed: {message}")
+
+ let firstPipe = v2Text.IndexOf('|')
+ let secondPipe = v2Text.IndexOf('|', firstPipe + 1)
+ let thirdPipe = v2Text.IndexOf('|', secondPipe + 1)
+ let v1Text = "LV_WORLD_SAVE_V1" + v2Text.Substring(thirdPipe)
+ File.WriteAllText(path, v1Text)
+ match WorldSave.loadFromFileWith path with
+ | Ok (_, occupation) -> Assert.AreEqual<Occupation.State option>(None, occupation)
+ | Error message -> Assert.Fail($"v1 read failed: {message}")
+
+ File.WriteAllText(path, WorldSave.saveWith (Some (Occupation.stateOf Occupation.Fisher)) world + "|task-today|bogus|offered|0|0|npc-none|tile-none")
+ match WorldSave.loadFromFile path with
+ | Ok _ -> Assert.Fail("unknown task template must be rejected")
+ | Error _ -> ())
+
+ [<TestMethod>]
+ member _.TrailingGarbageAfterTaskTailIsRejected () =
+ let world = DailyTaskHarness.worldWithAnnal ()
+ let state = { (Occupation.stateOf Occupation.Farmer) with Today = Some(DailyTaskHarness.taskForDay 4L) }
+ DailyTaskHarness.withTempSave (fun path ->
+ let text = WorldSave.saveWith (Some state) world
+ File.WriteAllText(path, text + "|junk")
+ match WorldSave.loadFromFile path with
+ | Ok _ -> Assert.Fail("trailing garbage after the task tail must be rejected")
+ | Error _ -> ())
+
+ [<TestMethod>]
+ member _.TaskCarryingSavesAreByteIdenticalAcrossRuns () =
+ let build () =
+ let world = DailyTaskHarness.worldWithAnnal ()
+ let state = { (Occupation.stateOf Occupation.Farmer) with Today = Some(DailyTaskHarness.taskForDay 9L) }
+ WorldSave.saveWith (Some state) world
+ Assert.AreEqual<string>(build (), build ())
diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs
index e0507ae..a3c37bd 100644
--- a/src/LivingVillage.Kernel/Occupation.fs
+++ b/src/LivingVillage.Kernel/Occupation.fs
@@ -43,15 +43,6 @@ module Occupation =
| Peddler -> { Kind = Peddler; InitialMoney = 90; InitialEnergy = 100.0f; InitialInventory = [ Sim.Food, 6; Sim.Spice, 4 ] }
| Scholar -> { Kind = Scholar; InitialMoney = 70; InitialEnergy = 100.0f; InitialInventory = [ Sim.Food, 10; Sim.Scroll, 2 ] }
- /// Placeholder carried in save v3 until the task-system slice lands (design §3).
- type State =
- { Profile: Profile
- TaskToken: int
- StoryStage: int }
-
- let stateOf (kind: Kind) : State =
- { Profile = profileOf kind; TaskToken = 0; StoryStage = 0 }
-
// ---- 每日任务(design §3):Kernel 侧纯函数片,不进 Sim.step 数值路径 ----
type TaskState =
@@ -169,3 +160,14 @@ module Occupation =
OfferedTick = offered
DueTick = offered + Sim.ticksPerDay - 1L
State = Offered }
+
+ /// 挂在玩家侧的职业状态,随存档 v3 保存(design §2)。
+ /// Today = 当日任务(§3);TaskToken 保留为 v3 前缀占位扩展位(恒 0,暂未启用)。
+ type State =
+ { Profile: Profile
+ TaskToken: int
+ Today: DailyTask option
+ StoryStage: int }
+
+ let stateOf (kind: Kind) : State =
+ { Profile = profileOf kind; TaskToken = 0; Today = None; StoryStage = 0 }
diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs
index 2d3088c..1ce1dcb 100644
--- a/src/LivingVillage.Kernel/WorldSave.fs
+++ b/src/LivingVillage.Kernel/WorldSave.fs
@@ -407,6 +407,82 @@ module WorldSave =
Kind = readAnnalKind reader (label + ".kind")
Summary = readText reader (label + ".summary") }
+ let private writeTaskTemplateId tokens template =
+ match template with
+ | Occupation.DeliverGrain -> add tokens "deliver-grain"
+ | Occupation.HelpWork -> add tokens "help-work"
+ | Occupation.TillSoil -> add tokens "till-soil"
+ | Occupation.NightCatch -> add tokens "night-catch"
+ | Occupation.SellFish -> add tokens "sell-fish"
+ | Occupation.MarketInquiry -> add tokens "market-inquiry"
+
+ let private readTaskTemplateId (reader: TokenReader) label =
+ match reader.Take(label) with
+ | "deliver-grain" -> Occupation.DeliverGrain
+ | "help-work" -> Occupation.HelpWork
+ | "till-soil" -> Occupation.TillSoil
+ | "night-catch" -> Occupation.NightCatch
+ | "sell-fish" -> Occupation.SellFish
+ | "market-inquiry" -> Occupation.MarketInquiry
+ | _ -> invalid (sprintf "invalid %s" label)
+
+ let private writeTaskState tokens state =
+ match state with
+ | Occupation.Offered -> add tokens "offered"
+ | Occupation.Active -> add tokens "active"
+ | Occupation.Done -> add tokens "done"
+ | Occupation.Failed -> add tokens "failed"
+
+ let private readTaskState (reader: TokenReader) label =
+ match reader.Take(label) with
+ | "offered" -> Occupation.Offered
+ | "active" -> Occupation.Active
+ | "done" -> Occupation.Done
+ | "failed" -> Occupation.Failed
+ | _ -> invalid (sprintf "invalid %s" label)
+
+ /// v3 today-task 尾段(design §3):紧跟 annals 之后,仅在有任务时写入。
+ /// 旧 v3(P10 版式)无此尾段 → 新 reader 读出 Today = None。
+ let private writeDailyTask tokens (task: Occupation.DailyTask) =
+ add tokens "task-today"
+ writeTaskTemplateId tokens task.TemplateId
+ writeTaskState tokens task.State
+ addInt64 tokens task.OfferedTick
+ addInt64 tokens task.DueTick
+ match task.TargetNpc with
+ | None -> add tokens "npc-none"
+ | Some npc ->
+ add tokens "npc-some"
+ writeNpcId tokens npc
+ match task.TargetTile with
+ | None -> add tokens "tile-none"
+ | Some (tileX, tileY) ->
+ add tokens "tile-some"
+ addInt tokens tileX
+ addInt tokens tileY
+
+ let private readDailyTask (reader: TokenReader) : Occupation.DailyTask =
+ let template = readTaskTemplateId reader "task.template"
+ let state = readTaskState reader "task.state"
+ let offeredTick = readInt64 reader "task.offered-tick"
+ let dueTick = readInt64 reader "task.due-tick"
+ let targetNpc =
+ match reader.Take("task.target-npc") with
+ | "npc-none" -> None
+ | "npc-some" -> Some(readNpcId reader "task.target-npc.id")
+ | _ -> invalid "invalid task.target-npc"
+ let targetTile =
+ match reader.Take("task.target-tile") with
+ | "tile-none" -> None
+ | "tile-some" -> Some(readInt reader "task.target-tile.x", readInt reader "task.target-tile.y")
+ | _ -> invalid "invalid task.target-tile"
+ { TemplateId = template
+ TargetNpc = targetNpc
+ TargetTile = targetTile
+ OfferedTick = offeredTick
+ DueTick = dueTick
+ State = state }
+
let private writeMind tokens (value: Mind) =
writeNeeds tokens value.Needs
writePersonality tokens value.Personality
@@ -478,6 +554,8 @@ module WorldSave =
/// today's v2 output).
/// v3 saves are emitted only when an occupation is attached; the default
/// (occupation-free) path stays byte-identical to today's v2 output.
+ /// When the occupation carries Today (design §3), a today-task tail segment
+ /// is appended after the annals.
let saveWith (occupation: Occupation.State option) (world: World) : string =
let tokens = ResizeArray<string>()
match occupation with
@@ -504,9 +582,19 @@ module WorldSave =
world.Rumors |> List.iter (writeRumor tokens)
addInt tokens world.Annals.Length
world.Annals |> List.iter (writeAnnal tokens)
+ match occupation with
+ | Some state ->
+ match state.Today with
+ | Some task -> writeDailyTask tokens task
+ | None -> ()
+ | None -> ()
String.Join("|", tokens)
- let load (text: string) : Result<World, string> =
+ /// Full parser: world plus the v3 occupation companion (None for v1/v2 or
+ /// v3 without a recognized kind). The optional today-task tail after the
+ /// annals is validated in both cases; only its value is dropped when there
+ /// is no occupation state to attach it to.
+ let private parse (text: string) : Result<World * Occupation.State option, string> =
if isNull text then
Error "save text is null"
else
@@ -535,7 +623,7 @@ module WorldSave =
| "peddler" -> Some Occupation.Peddler
| "scholar" -> Some Occupation.Scholar
| _ -> None
- occupationOption <- kind |> Option.map (fun kind -> { Profile = Occupation.profileOf kind; TaskToken = task; StoryStage = stage })
+ occupationOption <- kind |> Option.map (fun kind -> { Profile = Occupation.profileOf kind; TaskToken = task; Today = None; StoryStage = stage })
| _ -> ()
let tick = readInt64 reader "tick"
let time = readFloat reader "time"
@@ -550,24 +638,37 @@ module WorldSave =
let rumors = List.init rumorCount (fun index -> readRumor reader (sprintf "rumor[%d]" index))
let annalCount = readCount reader "annals"
let annals = List.init annalCount (fun index -> readAnnal reader (sprintf "annal[%d]" index))
+ let todayTask =
+ if reader.Remaining > 0 then
+ match reader.Take("task.today") with
+ | "task-today" -> Some(readDailyTask reader)
+ | _ -> invalid "trailing save data"
+ else None
if reader.Remaining <> 0 then
invalid "trailing save data"
+ let occupation =
+ occupationOption
+ |> Option.map (fun state -> { state with Today = todayTask })
Ok
- { Tick = tick
- Time = time
- Rng = rng
- Avatar = avatar
- NoHost = noHost
- Npcs = npcs
- Events = events
- Rumors = rumors
- Annals = annals }
+ ({ Tick = tick
+ Time = time
+ Rng = rng
+ Avatar = avatar
+ NoHost = noHost
+ Npcs = npcs
+ Events = events
+ Rumors = rumors
+ Annals = annals },
+ occupation)
with
| SaveParseError message -> Error message
| :? FormatException as ex -> Error(sprintf "invalid save: %s" ex.Message)
| :? OverflowException as ex -> Error(sprintf "invalid save: %s" ex.Message)
| :? ArgumentException as ex -> Error(sprintf "invalid save: %s" ex.Message)
+ let load (text: string) : Result<World, string> =
+ parse text |> Result.map (fun (world, _) -> world)
+
/// v2 output (no occupation) — byte-identical to the historic format.
let save (world: World) : string = saveWith None world
@@ -577,29 +678,13 @@ module WorldSave =
let saveToFileWith (occupation: Occupation.State option) (path: string) (world: World) : unit =
File.WriteAllText(path, saveWith occupation world, UTF8Encoding(false))
- let loadFromFile (path: string) : Result<World, string> =
+ /// v3 companion reader: (world, occupation option incl. today task tail);
+ /// None for v1/v2 or v3 w/o kind.
+ let loadFromFileWith (path: string) : Result<World * Occupation.State option, string> =
try
- File.ReadAllText(path) |> load
+ File.ReadAllText path |> parse
with
| :? IOException as ex -> Error(sprintf "could not read save: %s" ex.Message)
- /// v3 companion reader: (world, occupation option); None for v1/v2 or v3 w/o kind.
- let loadFromFileWith (path: string) : Result<World * Occupation.State option, string> =
- match loadFromFile path with
- | Error e -> Error e
- | Ok world ->
- try
- let text = File.ReadAllText path
- let tokens = text.Split([| '|' |])
- if tokens.[0] <> "LV_WORLD_SAVE_V3" then
- Ok(world, None)
- else
- match tokens.[3] with
- | "farmer" -> Ok(world, Some (Occupation.stateOf Occupation.Farmer))
- | "fisher" -> Ok(world, Some (Occupation.stateOf Occupation.Fisher))
- | "peddler" -> Ok(world, Some (Occupation.stateOf Occupation.Peddler))
- | "scholar" -> Ok(world, Some (Occupation.stateOf Occupation.Scholar))
- | _ -> Ok(world, None)
- with
- | :? IOException as ex -> Error(sprintf "could not read save: %s" ex.Message)
- | :? FormatException as ex -> Error(sprintf "invalid save: %s" ex.Message)
+ let loadFromFile (path: string) : Result<World, string> =
+ loadFromFileWith path |> Result.map (fun (world, _) -> world)