Skip to content

Commit fca2cf2

Browse files
author
Sorra
authored
Merge pull request #418 from TheWizardsCode/wl-CG-0MMJ8S90S1P2HW74-save-load-infra
CG-0MMJ8S90S1P2HW74: add versioned save/load infrastructure
2 parents 8c7cdcc + 2a6e202 commit fca2cf2

7 files changed

Lines changed: 931 additions & 2 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {
2+
SaveLoadStore,
3+
type SaveSerializer,
4+
} from '../../src/core-engine';
5+
import {
6+
type MainStreetCampaignProgress,
7+
type MainStreetSerializedState,
8+
type MainStreetState,
9+
serializeMainStreetState,
10+
deserializeMainStreetState,
11+
} from './MainStreetState';
12+
13+
export const MAIN_STREET_SAVE_SCHEMA_VERSION = 1;
14+
export const MAIN_STREET_CAMPAIGN_SCHEMA_VERSION = 1;
15+
export const MAIN_STREET_GAME_TYPE = 'main-street';
16+
export const MAIN_STREET_RUN_SLOT = 'turn-start';
17+
export const MAIN_STREET_CAMPAIGN_SLOT = 'campaign-default';
18+
19+
export const mainStreetStateSerializer: SaveSerializer<
20+
MainStreetState,
21+
MainStreetSerializedState
22+
> = {
23+
schemaVersion: MAIN_STREET_SAVE_SCHEMA_VERSION,
24+
serialize: serializeMainStreetState,
25+
deserialize: deserializeMainStreetState,
26+
};
27+
28+
export const mainStreetCampaignSerializer: SaveSerializer<
29+
MainStreetCampaignProgress,
30+
MainStreetCampaignProgress
31+
> = {
32+
schemaVersion: MAIN_STREET_CAMPAIGN_SCHEMA_VERSION,
33+
serialize: (state) => structuredClone(state),
34+
deserialize: (data) => structuredClone(data),
35+
};
36+
37+
export function createDefaultCampaignProgress(): MainStreetCampaignProgress {
38+
return {
39+
unlockedTiers: ['tier-1'],
40+
persistentReputation: 0,
41+
highestScore: 0,
42+
totalRuns: 0,
43+
totalWins: 0,
44+
lastUpdatedAt: new Date().toISOString(),
45+
};
46+
}
47+
48+
export async function saveTurnStartCheckpoint(
49+
store: SaveLoadStore,
50+
state: MainStreetState,
51+
slotId: string = MAIN_STREET_RUN_SLOT,
52+
): Promise<void> {
53+
await store.saveRunCheckpoint(
54+
MAIN_STREET_GAME_TYPE,
55+
slotId,
56+
mainStreetStateSerializer,
57+
state,
58+
);
59+
}
60+
61+
export async function loadTurnStartCheckpoint(
62+
store: SaveLoadStore,
63+
slotId: string = MAIN_STREET_RUN_SLOT,
64+
): Promise<MainStreetState | null> {
65+
return store.loadRunCheckpoint(
66+
MAIN_STREET_GAME_TYPE,
67+
slotId,
68+
mainStreetStateSerializer,
69+
);
70+
}
71+
72+
export async function saveCampaignProgress(
73+
store: SaveLoadStore,
74+
progress: MainStreetCampaignProgress,
75+
slotId: string = MAIN_STREET_CAMPAIGN_SLOT,
76+
): Promise<void> {
77+
await store.saveCampaignProgress(
78+
MAIN_STREET_GAME_TYPE,
79+
slotId,
80+
mainStreetCampaignSerializer,
81+
progress,
82+
);
83+
}
84+
85+
export async function loadCampaignProgress(
86+
store: SaveLoadStore,
87+
slotId: string = MAIN_STREET_CAMPAIGN_SLOT,
88+
): Promise<MainStreetCampaignProgress | null> {
89+
return store.loadCampaignProgress(
90+
MAIN_STREET_GAME_TYPE,
91+
slotId,
92+
mainStreetCampaignSerializer,
93+
);
94+
}

example-games/main-street/MainStreetState.ts

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,53 @@ export interface MainStreetState {
176176
finalScore: number;
177177
/** The seed string used for this game. */
178178
seed: string;
179+
/** Numeric seed derived from the seed string (used for restore). */
180+
numericSeed: number;
181+
/** Number of RNG draws consumed so far (used for deterministic restore). */
182+
rngCalls: number;
179183
/** The RNG function for this game (seeded, deterministic). */
180184
rng: () => number;
181185
/** Chronological log of game activities for the UI activity log panel. */
182186
activityLog: LogEntry[];
183187
}
184188

189+
export interface MainStreetSerializedState {
190+
config: GameConfig;
191+
turn: number;
192+
phase: DayPhase;
193+
streetGrid: (BusinessCard | null)[];
194+
market: MarketState;
195+
resourceBank: ResourceBank;
196+
decks: {
197+
business: BusinessCard[];
198+
event: EventCard[];
199+
upgrade: UpgradeCard[];
200+
};
201+
challengesCompleted: string[];
202+
activeChallenges: {
203+
challengeId: string;
204+
completed: boolean;
205+
}[];
206+
heldEvent: EventCard | null;
207+
incidentQueue: EventCard[];
208+
gameResult: GameResult;
209+
endReason: EndReason;
210+
finalScore: number;
211+
seed: string;
212+
numericSeed: number;
213+
rngCalls: number;
214+
activityLog: LogEntry[];
215+
}
216+
217+
export interface MainStreetCampaignProgress {
218+
unlockedTiers: string[];
219+
persistentReputation: number;
220+
highestScore: number;
221+
totalRuns: number;
222+
totalWins: number;
223+
lastUpdatedAt: string;
224+
}
225+
185226
// ── Setup Options ───────────────────────────────────────────
186227

187228
/** Options for setting up a new Main Street game. */
@@ -246,7 +287,16 @@ function fillMarketSlots<T>(deck: T[], count: number): T[] {
246287
export function setupMainStreetGame(options: MainStreetSetupOptions = {}): MainStreetState {
247288
const seed = options.seed ?? generateSeedString();
248289
const numericSeed = seedToNumber(seed);
249-
const rng = createSeededRng(numericSeed);
290+
const baseRng = createSeededRng(numericSeed);
291+
let rngCalls = 0;
292+
let state!: MainStreetState;
293+
const rng = (): number => {
294+
rngCalls += 1;
295+
if (state) {
296+
state.rngCalls = rngCalls;
297+
}
298+
return baseRng();
299+
};
250300

251301
// Resolve difficulty preset into runtime config
252302
const config = getPreset(options.difficulty);
@@ -288,7 +338,7 @@ export function setupMainStreetGame(options: MainStreetSetupOptions = {}): MainS
288338
}
289339

290340
// Build initial state -- use config values instead of hard-coded constants
291-
const state: MainStreetState = {
341+
state = {
292342
config,
293343
turn: 1,
294344
phase: 'DayStart',
@@ -311,6 +361,8 @@ export function setupMainStreetGame(options: MainStreetSetupOptions = {}): MainS
311361
endReason: null,
312362
finalScore: 0,
313363
seed,
364+
numericSeed,
365+
rngCalls,
314366
rng,
315367
activityLog: [],
316368
};
@@ -324,3 +376,83 @@ export function setupMainStreetGame(options: MainStreetSetupOptions = {}): MainS
324376

325377
return state;
326378
}
379+
380+
/**
381+
* Serializes Main Street runtime state into a JSON-safe checkpoint shape.
382+
*/
383+
export function serializeMainStreetState(state: MainStreetState): MainStreetSerializedState {
384+
return {
385+
config: structuredClone(state.config),
386+
turn: state.turn,
387+
phase: state.phase,
388+
streetGrid: structuredClone(state.streetGrid),
389+
market: structuredClone(state.market),
390+
resourceBank: structuredClone(state.resourceBank),
391+
decks: structuredClone(state.decks),
392+
challengesCompleted: [...state.challengesCompleted],
393+
activeChallenges: state.activeChallenges.map((ac) => ({
394+
challengeId: ac.challenge.id,
395+
completed: ac.completed,
396+
})),
397+
heldEvent: structuredClone(state.heldEvent),
398+
incidentQueue: structuredClone(state.incidentQueue),
399+
gameResult: state.gameResult,
400+
endReason: state.endReason,
401+
finalScore: state.finalScore,
402+
seed: state.seed,
403+
numericSeed: state.numericSeed,
404+
rngCalls: state.rngCalls,
405+
activityLog: structuredClone(state.activityLog),
406+
};
407+
}
408+
409+
/**
410+
* Rehydrates runtime state from a serialized checkpoint.
411+
*/
412+
export function deserializeMainStreetState(saved: MainStreetSerializedState): MainStreetState {
413+
const baseRng = createSeededRng(saved.numericSeed);
414+
for (let i = 0; i < saved.rngCalls; i++) {
415+
baseRng();
416+
}
417+
418+
let rngCalls = saved.rngCalls;
419+
let state!: MainStreetState;
420+
const rng = (): number => {
421+
rngCalls += 1;
422+
state.rngCalls = rngCalls;
423+
return baseRng();
424+
};
425+
426+
state = {
427+
config: structuredClone(saved.config),
428+
turn: saved.turn,
429+
phase: saved.phase,
430+
streetGrid: structuredClone(saved.streetGrid),
431+
market: structuredClone(saved.market),
432+
resourceBank: structuredClone(saved.resourceBank),
433+
decks: structuredClone(saved.decks),
434+
challengesCompleted: [...saved.challengesCompleted],
435+
activeChallenges: saved.activeChallenges.map((ac) => {
436+
const challenge = CHALLENGE_TEMPLATES.find((tpl) => tpl.id === ac.challengeId);
437+
if (!challenge) {
438+
throw new Error(`Unknown challenge id in save: ${ac.challengeId}`);
439+
}
440+
return {
441+
challenge,
442+
completed: ac.completed,
443+
};
444+
}),
445+
heldEvent: structuredClone(saved.heldEvent),
446+
incidentQueue: structuredClone(saved.incidentQueue),
447+
gameResult: saved.gameResult,
448+
endReason: saved.endReason,
449+
finalScore: saved.finalScore,
450+
seed: saved.seed,
451+
numericSeed: saved.numericSeed,
452+
rngCalls: saved.rngCalls,
453+
rng,
454+
activityLog: structuredClone(saved.activityLog),
455+
};
456+
457+
return state;
458+
}

0 commit comments

Comments
 (0)