summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/LivingVillage.Desktop/WorldBootstrap.fs25
-rw-r--r--src/LivingVillage.Kernel.Tests/LivingVillage.Kernel.Tests.fsproj2
-rw-r--r--src/LivingVillage.Kernel.Tests/M6aTests.fs66
-rw-r--r--src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj1
-rw-r--r--src/LivingVillage.Kernel/Occupation.fs53
-rw-r--r--src/LivingVillage.Kernel/Sim.fs6
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs72
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)