diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/DailyTaskTests.fs | 109 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 20 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/WorldSave.fs | 151 |
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) |
