feat(phase-b): GameSession snapshot save/load (JSON)

ToJson/FromJson round-trip the persistent global bank; play --save-state <file>
persists it, --state <file> restores it — so an expensive booted state (23646
globals) is captured once and reused without re-running *INIT, and it's the
foundation for real save-file work. Tests: unit round-trip + booted-state
survives snapshot (skill data intact). Engine 18/18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-07 00:26:23 -04:00
parent 0738f044c1
commit 0f4f44ebf5
3 changed files with 55 additions and 1 deletions

View File

@@ -77,7 +77,12 @@ if (args[0] == "play")
var userScenes = args.Skip(1).Where(a => a.ToUpperInvariant().EndsWith(".BIN")).ToList();
if (userScenes.Count == 0) { Console.WriteLine("usage: play [--boot] <SCENE.BIN...> [0xADDR=VAL ...]"); return 1; }
var scenes = (boot ? bootScripts.Concat(userScenes) : userScenes).ToList();
var session = new GameSession();
// --state <file>: start from a saved snapshot (e.g. a pre-booted state) instead of booting fresh.
string? StateArg(string flag) { int i = Array.IndexOf(args, flag); return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; }
var loadState = StateArg("--state");
var saveState = StateArg("--save-state");
var session = loadState != null ? GameSession.FromJson(File.ReadAllText(loadState)) : new GameSession();
if (loadState != null) Console.WriteLine($"[state] loaded {session.Globals.Count} globals from {loadState}");
foreach (var s in args.Skip(1).Where(a => a.Contains('=')))
{
var kv = s.Split('=');
@@ -94,6 +99,7 @@ if (args[0] == "play")
Console.WriteLine($" {name,-14} {r.Emitted.Count,4} lines, {r.Steps,7} steps (halt: {r.Halt})");
}
Console.WriteLine($"total: {totalLines} lines across {scenes.Count} scene(s); {session.Globals.Count} globals carried");
if (saveState != null) { File.WriteAllText(saveState, session.ToJson()); Console.WriteLine($"[state] saved -> {saveState}"); }
return 0;
}

View File

@@ -90,6 +90,30 @@ public class GameSessionTests
Assert.True(session.Globals.Count > 1000, $"SKINIT should populate the skill table (got {session.Globals.Count})");
}
[Fact]
public void SnapshotRoundTripsState()
{
var s = new GameSession();
s.Seed(0x10, 42); s.Seed(0x20, -7); s.SeedString(0x30, "リリィ");
var t = GameSession.FromJson(s.ToJson());
Assert.Equal(42, t.Globals[0x10]);
Assert.Equal(-7, t.Globals[0x20]);
Assert.Equal("リリィ", t.GlobalStrings[0x30]);
Assert.Equal(s.Globals.Count, t.Globals.Count);
}
[Fact]
public void BootedStateSurvivesSnapshot()
{
var s = new GameSession();
s.RunScene(Sys4Loader.Load(Paths.Scripts()["SKINIT.BIN"], Table), Table, new CaptureHost());
var t = GameSession.FromJson(s.ToJson()); // snapshot the expensive booted state, rebuild
Assert.Equal("飛行", t.GlobalStrings[0x23a3]);
Assert.Equal(30, t.Globals[0xa6e5b]);
Assert.Equal(s.Globals.Count, t.Globals.Count);
Assert.Equal(s.GlobalStrings.Count, t.GlobalStrings.Count);
}
[Fact]
public void SeedingFormFlagChangesBehavior()
{

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using Age.Engine.Hosting;
using Age.Engine.Model;
@@ -37,6 +38,29 @@ public sealed class GameSession
return new SceneResult(vm.Emitted.ToList(), vm.HaltReason, vm.Steps);
}
/// <summary>Serialize the persistent state to JSON (globals + string globals, keyed by decimal address).
/// Lets an expensive booted state be snapshotted and reused; foundation for save-file work.</summary>
public string ToJson()
{
var snap = new StateSnapshot(
Globals.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value),
GlobalStrings.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value));
return JsonSerializer.Serialize(snap,
new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
}
/// <summary>Rebuild a session from a <see cref="ToJson"/> snapshot.</summary>
public static GameSession FromJson(string json)
{
var s = new GameSession();
var snap = JsonSerializer.Deserialize<StateSnapshot>(json) ?? new StateSnapshot(new(), new());
foreach (var kv in snap.Globals) s.Globals[int.Parse(kv.Key)] = kv.Value;
foreach (var kv in snap.Strings) s.GlobalStrings[int.Parse(kv.Key)] = kv.Value;
return s;
}
private sealed record StateSnapshot(Dictionary<string, long> Globals, Dictionary<string, string> Strings);
}
/// <summary>The observable result of running one scene into a <see cref="GameSession"/>.</summary>