summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel/WorldSave.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 17:18:11 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 17:18:11 +0800
commitcf214889490a62ba4fe84fb0055bc2133776e8a2 (patch)
tree0408fbd82e95ed55f39f8da3ccd3d2259e999313 /src/LivingVillage.Kernel/WorldSave.fs
parent87bdfe273555c5ae5f698b3b89fa6c39d84d0c33 (diff)
downloadliving-village-cf214889490a62ba4fe84fb0055bc2133776e8a2.tar.gz
feat(occupation): 职业系统第一片 — Kernel 结构 / bootstrap 注入 / 存档 v3
Occupation.fs(Kind/Profile/State + 农夫/渔夫/货郎/书生 中文名常量,按设计 §1 表给初始 Money/Energy/Inventory);ItemKind 扩出 Fish/Spice/Scroll(basePrice 分支补齐,Food 10.0f 数值路径不变 → 无职业 digest 不动);WorldBootstrap.initialWorldWithOccupation 默认 None = 旧行为(Money/Energy 覆盖,库存字段先留数据侧、物理挂接留交易单——取舍见 design §2-C 反对加字段);WorldSave: v3 头仅在有职业时写入(保存 saveToFileWith / loadFromFileWith,v1/v2/v3 无职业类型默认,token 顺序 version|w|h|kind|task|story|…),默认路径 save 输出逐字节等同现行 v2;P10 测试 5 例(§1 表值、v3 round-trip、v1/v2 不吞档、双 seed 逐字节、中文名);Kernel.Tests 引用 Desktop 以驱动 WorldBootstrap(取舍:不做测试专用 shim)。TaskToken/StoryStage 为 v3 占位(任务系统后续单实现)。
Diffstat (limited to 'src/LivingVillage.Kernel/WorldSave.fs')
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs72
1 files changed, 68 insertions, 4 deletions
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)