summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Kernel/WorldSave.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 18:49:50 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 18:49:50 +0800
commit27f0ca0597e2799e2a1ed431aadeb559c6dfbcf2 (patch)
treef7465c43b1e421fc51641e81ee0f55d4845416c5 /src/LivingVillage.Kernel/WorldSave.fs
parentf8830705272cfa2c877cd0b984f61d1fa7a77ec2 (diff)
downloadliving-village-27f0ca0597e2799e2a1ed431aadeb559c6dfbcf2.tar.gz
feat(occupation): P11 存档 v3 today-task 尾段(annals 后追加)+ 双向兼容
Occupation.State 增 Today: DailyTask option(State 声明移至 DailyTask 之后;TaskToken 保留为 v3 前缀占位扩展位)。WorldSave:saveWith 有任务时在 annals 之后追加尾段 task-today|<template>|<state>|<offered>|<due>|npc-none/some|tile-none/some;无任务/无职业不追加(默认 None = 旧行为,v2 逐字节不变)。load 重构为内部 parse 返回 (World, Occupation.State option):尾段可选——旧 v3(P10 版式无尾段)新 reader 读出 Today=None;v1/v2 无任务;尾段后多余 token 或未知模板 token 拒绝(不吞档)。loadFromFileWith 顺手修正:不再经 stateOf 重置,前缀 task/stage 与 Today 全量保真。5 例测试:尾段位置(marker 在 annal 之后)+ 全字段 round-trip + 重存逐字节一致、无任务 v3 旧版式、v1/v2 无尾段 + 未知模板拒绝、尾段后垃圾拒绝、字节级双跑一致。
Diffstat (limited to 'src/LivingVillage.Kernel/WorldSave.fs')
-rw-r--r--src/LivingVillage.Kernel/WorldSave.fs151
1 files changed, 118 insertions, 33 deletions
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)