diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Desktop/WorldBootstrap.fs | 25 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel.Tests/M6aTests.fs | 66 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Occupation.fs | 53 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 6 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/WorldSave.fs | 72 |
7 files changed, 215 insertions, 10 deletions
diff --git a/src/LivingVillage.Desktop/WorldBootstrap.fs b/src/LivingVillage.Desktop/WorldBootstrap.fs index d6b906d..0e8ea30 100644 --- a/src/LivingVillage.Desktop/WorldBootstrap.fs +++ b/src/LivingVillage.Desktop/WorldBootstrap.fs @@ -18,16 +18,28 @@ module WorldBootstrap = let private daylightStartTick = 6L * Sim.ticksPerDay / 24L - let initialWorldForDaylight (startAtDaylight: bool) (seed: uint64) (count: int) : World = + let initialWorldWithOccupation (startAtDaylight: bool) (seed: uint64) (count: int) (occupation: Occupation.State option) : World = if count < 0 || count > npcSpawnTiles.Length then invalidArg (nameof count) "Desktop bootstrap supports at most 30 NPCs." let startTick = if startAtDaylight then daylightStartTick else 0L let baseWorld = Sim.initialWorldN seed count let avatar = - { baseWorld.Avatar with - Pos = avatarSpawnPosition - Mind = { baseWorld.Avatar.Mind with Target = avatarSpawnPosition } } + let baseAvatar = + { baseWorld.Avatar with + Pos = avatarSpawnPosition + Mind = { baseWorld.Avatar.Mind with Target = avatarSpawnPosition } } + match occupation with + | None -> baseAvatar + | Some state -> + let needs = baseAvatar.Mind.Needs + { baseAvatar with + Mind = + { baseAvatar.Mind with + Needs = + { needs with + Money = float32 state.Profile.InitialMoney + Energy = state.Profile.InitialEnergy } } } let npcs = baseWorld.Npcs |> Array.mapi (fun index npc -> @@ -40,5 +52,8 @@ module WorldBootstrap = Avatar = avatar Npcs = npcs } + let initialWorldForDaylight (startAtDaylight: bool) (seed: uint64) (count: int) : World = + initialWorldWithOccupation startAtDaylight seed count None + let initialWorld (seed: uint64) (count: int) : World = - initialWorldForDaylight false seed count + initialWorldWithOccupation false seed count None diff --git a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj index f618dc9..f7a50d5 100644 --- a/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj +++ b/src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj @@ -22,7 +22,7 @@ </ItemGroup> <ItemGroup> - <ProjectReference Include="..\LivingVillage.Kernel\LivingVillage.Kernel.fsproj" /> + <ProjectReference Include="..\LivingVillage.Desktop\LivingVillage.Desktop.fsproj" /> <ProjectReference Include="..\LivingVillage.Headless\LivingVillage.Headless.fsproj" /> </ItemGroup> diff --git a/src/LivingVillage.Kernel.Tests/M6aTests.fs b/src/LivingVillage.Kernel.Tests/M6aTests.fs index 7f8d5ea..7184a75 100644 --- a/src/LivingVillage.Kernel.Tests/M6aTests.fs +++ b/src/LivingVillage.Kernel.Tests/M6aTests.fs @@ -5,6 +5,7 @@ open System.IO open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Kernel open LivingVillage.Kernel.Sim +open LivingVillage.Desktop module private M6aHarness = @@ -226,3 +227,68 @@ type M6aTests () = Assert.AreEqual<int>(0, SimulationControl.stepsPerFrameWithLegacy legacySteps (SimulationControl.togglePause control)) let manuallySelected = SimulationControl.setSpeed TwoX control Assert.AreEqual<int>(2, SimulationControl.stepsPerFrameWithLegacy None manuallySelected) + + [<TestMethod>] + member _.OccupationProfilesMatchTheDesignTable () = + let farmer = LivingVillage.Kernel.Occupation.profileOf Occupation.Farmer + Assert.AreEqual<int>(60, farmer.InitialMoney) + Assert.AreEqual<float32>(100.0f, farmer.InitialEnergy) + Assert.AreEqual<int>(12, snd (List.head farmer.InitialInventory)) + let fisher = Occupation.profileOf Occupation.Fisher + Assert.AreEqual<int>(45, fisher.InitialMoney) + Assert.AreEqual<float32>(95.0f, fisher.InitialEnergy) + Assert.AreEqual<int>(8, snd (List.head fisher.InitialInventory)) + Assert.AreEqual<int>(3, snd (List.nth fisher.InitialInventory 1)) + let peddler = Occupation.profileOf Occupation.Peddler + Assert.AreEqual<int>(90, peddler.InitialMoney) + Assert.AreEqual<int>(6, snd (List.head peddler.InitialInventory)) + Assert.AreEqual<int>(4, snd (List.nth peddler.InitialInventory 1)) + let scholar = Occupation.profileOf Occupation.Scholar + Assert.AreEqual<int>(70, scholar.InitialMoney) + Assert.AreEqual<int>(10, snd (List.head scholar.InitialInventory)) + Assert.AreEqual<int>(2, snd (List.nth scholar.InitialInventory 1)) + + [<TestMethod>] + member _.WorldSaveV3RoundTripsOccupation () = + let world = Sim.initialWorld (uint64 11) + let state = Occupation.stateOf Occupation.Fisher + WorldSave.saveToFileWith (Some state) "p10-v3.save" world + let text = System.IO.File.ReadAllText "p10-v3.save" + Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "v3 header expected") + match WorldSave.loadFromFileWith "p10-v3.save" with + | Ok (_loaded, restored) -> + match restored with + | Some st -> + Assert.AreEqual<int>(45, st.Profile.InitialMoney) + Assert.AreEqual<int>(0, st.TaskToken) + Assert.AreEqual<int>(0, st.StoryStage) + | None -> Assert.Fail("v3 round trip lost the occupation") + | Error message -> Assert.Fail(sprintf "v3 round trip failed: %s" message) + + [<TestMethod>] + member _.WorldSaveLegacySavesStillReadWithoutOccupation () = + let world = Sim.initialWorld (uint64 42) + WorldSave.saveToFile "p10-v2.save" world + Assert.IsTrue((System.IO.File.ReadAllText "p10-v2.save").StartsWith("LV_WORLD_SAVE_V2")) + match WorldSave.loadFromFileWith "p10-v2.save" with + | Ok (_w, occ) -> Assert.AreEqual<obj>(null, occ |> Option.map (fun _ -> box 1) |> Option.defaultValue null) + | Error message -> Assert.Fail(sprintf "v2 read failed: %s" message) + Sim.configureBounds 64 48 + + [<TestMethod>] + member _.OccupationBootstrapSavesAreByteIdenticalForSameSeed () = + let seed = uint64 4242 + let state1 = Some (Occupation.stateOf Occupation.Peddler) + let state2 = Some (Occupation.stateOf Occupation.Peddler) + let world1 = WorldBootstrap.initialWorldWithOccupation false seed 4 state1 + let world2 = WorldBootstrap.initialWorldWithOccupation false seed 4 state2 + let text1 = WorldSave.saveWith state1 world1 + let text2 = WorldSave.saveWith state2 world2 + Assert.AreEqual<string>(text1, text2) + + [<TestMethod>] + member _.OccupationChineseNamesAreKernelSideConstants () = + Assert.AreEqual<string>("农夫", Occupation.nameOf Occupation.Farmer) + Assert.AreEqual<string>("渔夫", Occupation.nameOf Occupation.Fisher) + Assert.AreEqual<string>("货郎", Occupation.nameOf Occupation.Peddler) + Assert.AreEqual<string>("书生", Occupation.nameOf Occupation.Scholar) diff --git a/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj index 2a3126c..c6b3bf9 100644 --- a/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj +++ b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj @@ -7,6 +7,7 @@ <ItemGroup> <Compile Include="Rng.fs" /> <Compile Include="Sim.fs" /> + <Compile Include="Occupation.fs" /> <Compile Include="SimulationControl.fs" /> <Compile Include="WorldSave.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Kernel/Occupation.fs b/src/LivingVillage.Kernel/Occupation.fs new file mode 100644 index 0000000..28008f5 --- /dev/null +++ b/src/LivingVillage.Kernel/Occupation.fs @@ -0,0 +1,53 @@ +namespace LivingVillage.Kernel + +/// Occupation system first slice (design §1/§2, docs/design/occupation-system-design.md). +/// Data lives beside the player only — NPCs are never occupied. +/// Nothing here runs inside Sim.step; WorldBootstrap stamps the profile onto the +/// world's avatar at creation time, which keeps the numeric state path untouched. +module Occupation = + + type Kind = + | Farmer + | Fisher + | Peddler + | Scholar + + /// Chinese labels, shared for a later UI slice (农夫 / 渔夫 / 货郎 / 书生). + let nameOf (kind: Kind) : string = + match kind with + | Farmer -> "农夫" + | Fisher -> "渔夫" + | Peddler -> "货郎" + | Scholar -> "书生" + + let saveToken (kind: Kind) : string = + match kind with + | Farmer -> "farmer" + | Fisher -> "fisher" + | Peddler -> "peddler" + | Scholar -> "scholar" + + let all : Kind list = [ Farmer; Fisher; Peddler; Scholar ] + + type Profile = + { Kind: Kind + InitialMoney: int + InitialEnergy: float32 + InitialInventory: (Sim.ItemKind * int) list } + + /// Initial values straight from the approved design §1 table. + let profileOf (kind: Kind) : Profile = + match kind with + | Farmer -> { Kind = Farmer; InitialMoney = 60; InitialEnergy = 100.0f; InitialInventory = [ Sim.Food, 12 ] } + | Fisher -> { Kind = Fisher; InitialMoney = 45; InitialEnergy = 95.0f; InitialInventory = [ Sim.Food, 8; Sim.Fish, 3 ] } + | 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 } diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs index 9da7c46..5445e7d 100644 --- a/src/LivingVillage.Kernel/Sim.fs +++ b/src/LivingVillage.Kernel/Sim.fs @@ -39,6 +39,9 @@ module Sim = type ItemKind = | Food + | Fish + | Spice + | Scroll type NpcActionKind = | Eat @@ -529,6 +532,9 @@ module Sim = let basePrice = match request.Item with | Food -> 10.0f + | Fish -> 12.0f + | Spice -> 8.0f + | Scroll -> 15.0f let buyerUrgency = clamp ((100.0f - buyer.Mind.Needs.Hunger) / 100.0f) 0.0f 1.0f let stockPressure = 1.0f - clamp (float32 (inventoryQuantity request.Item seller.Inventory) / 20.0f) 0.0f 1.0f diff --git a/src/LivingVillage.Kernel/WorldSave.fs b/src/LivingVillage.Kernel/WorldSave.fs index e68a45f..2d3088c 100644 --- a/src/LivingVillage.Kernel/WorldSave.fs +++ b/src/LivingVillage.Kernel/WorldSave.fs @@ -113,10 +113,16 @@ module WorldSave = let private writeItemKind tokens item = match item with | Food -> add tokens "food" + | Fish -> add tokens "fish" + | Spice -> add tokens "spice" + | Scroll -> add tokens "scroll" let private readItemKind (reader: TokenReader) label = match reader.Take(label) with | "food" -> Food + | "fish" -> Fish + | "spice" -> Spice + | "scroll" -> Scroll | _ -> invalid (sprintf "invalid %s" label) let private writeActionKind tokens action = @@ -467,11 +473,24 @@ module WorldSave = Inventory = readInventory reader (sprintf "npc[%d].inventory" index) Mind = readMind reader (sprintf "npc[%d].mind" index) } - let save (world: World) : string = + /// v3 saves are produced only when an occupation is attached (design task + /// requires the default (occupation-free) path to stay byte-identical to + /// 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. + let saveWith (occupation: Occupation.State option) (world: World) : string = let tokens = ResizeArray<string>() - add tokens "LV_WORLD_SAVE_V2" + match occupation with + | None -> add tokens "LV_WORLD_SAVE_V2" + | Some state -> add tokens "LV_WORLD_SAVE_V3" addInt tokens mapWidthTiles addInt tokens mapHeightTiles + match occupation with + | Some state -> + add tokens (Occupation.saveToken state.Profile.Kind) + addInt tokens state.TaskToken + addInt tokens state.StoryStage + | None -> () addInt64 tokens world.Tick addFloat tokens world.Time addUInt64 tokens world.Rng.State @@ -494,12 +513,30 @@ module WorldSave = try let tokens = text.Split([| '|' |], StringSplitOptions.None) let reader = TokenReader(tokens) - match reader.Take("format") with - | "LV_WORLD_SAVE_V2" -> + let mutable occupationOption : Occupation.State option = None + let versionTaken = reader.Take("format") + match versionTaken with + | "LV_WORLD_SAVE_V3" -> + configureBounds (readInt reader "bounds.w") (readInt reader "bounds.h") + | v2 when v2 = "LV_WORLD_SAVE_V2" -> configureBounds (readInt reader "bounds.w") (readInt reader "bounds.h") | v1 when v1 = formatVersion -> configureBounds 64 48 | _ -> invalid "unsupported save format" + match versionTaken with + | "LV_WORLD_SAVE_V3" -> + let kindToken = reader.Take("occupation.kind") + let task = readInt reader "occupation.task" + let stage = readInt reader "occupation.stage" + let kind = + match kindToken with + | "farmer" -> Some Occupation.Farmer + | "fisher" -> Some Occupation.Fisher + | "peddler" -> Some Occupation.Peddler + | "scholar" -> Some Occupation.Scholar + | _ -> None + occupationOption <- kind |> Option.map (fun kind -> { Profile = Occupation.profileOf kind; TaskToken = task; StoryStage = stage }) + | _ -> () let tick = readInt64 reader "tick" let time = readFloat reader "time" let rng = { State = readUInt64 reader "rng" } @@ -531,11 +568,38 @@ module WorldSave = | :? OverflowException as ex -> Error(sprintf "invalid save: %s" ex.Message) | :? ArgumentException as ex -> Error(sprintf "invalid save: %s" ex.Message) + /// v2 output (no occupation) — byte-identical to the historic format. + let save (world: World) : string = saveWith None world + let saveToFile (path: string) (world: World) : unit = File.WriteAllText(path, save world, UTF8Encoding(false)) + 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> = try File.ReadAllText(path) |> load 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) |
