Skip to content

Commit d91eca7

Browse files
author
Sorra
committed
Merge wl-CG-0MMJ8S90S1P2HW74-smoke-vitest: Add save-load smoke tests to Vitest
2 parents 1cd90ce + 3fab553 commit d91eca7

1 file changed

Lines changed: 152 additions & 0 deletions

File tree

tests/main-street/save-load.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
22
import { SaveLoadStore } from '../../src/core-engine';
33
import { setupMainStreetGame } from '../../example-games/main-street/MainStreetState';
4+
import type { MainStreetState } from '../../example-games/main-street/MainStreetState';
45
import {
56
executeDayStart,
67
executeAction,
78
processEndOfTurn,
9+
computeScore,
10+
type PlayerAction,
811
} from '../../example-games/main-street/MainStreetEngine';
12+
import {
13+
getAffordableBusinessCards,
14+
getAffordableUpgradeCards,
15+
getEmptySlots,
16+
canPurchaseEvent,
17+
} from '../../example-games/main-street/MainStreetMarket';
918
import {
1019
createDefaultCampaignProgress,
1120
loadCampaignProgress,
@@ -103,4 +112,147 @@ describe('Main Street save/load integration', () => {
103112
const runSaves = await store.list('run-checkpoint', 'main-street');
104113
expect(runSaves).toHaveLength(0);
105114
});
115+
116+
it('deterministic restore: checkpoint-and-replay produces identical outcome', async () => {
117+
const SEED = 'smoke-deterministic-restore';
118+
const CHECKPOINT_AFTER = 3;
119+
const MAX_TURNS = 20;
120+
121+
/** Simple greedy strategy: buy cheapest business, play events, buy upgrades. */
122+
function chooseActions(state: MainStreetState): PlayerAction[] {
123+
const actions: PlayerAction[] = [];
124+
const empty = getEmptySlots(state);
125+
const affordable = getAffordableBusinessCards(state);
126+
affordable.sort((a, b) => a.cost - b.cost);
127+
128+
for (const card of affordable) {
129+
if (empty.length === 0) break;
130+
if (state.resourceBank.coins < card.cost) break;
131+
actions.push({ type: 'buy-business', cardId: card.id, slotIndex: empty.shift()! });
132+
break;
133+
}
134+
135+
if (state.heldEvent !== null) {
136+
actions.push({ type: 'play-event' });
137+
}
138+
139+
for (const card of state.market.investments) {
140+
if (card.family !== 'event') continue;
141+
if (canPurchaseEvent(state, card.id).legal) {
142+
actions.push({ type: 'buy-event', cardId: card.id });
143+
break;
144+
}
145+
}
146+
147+
const upgrades = getAffordableUpgradeCards(state);
148+
if (upgrades.length > 0) {
149+
const upg = upgrades[0];
150+
const slot = state.streetGrid.findIndex(
151+
(b) => b !== null && b.upgradePath === upg.targetBusiness && b.level < b.maxLevel,
152+
);
153+
if (slot >= 0) {
154+
actions.push({ type: 'buy-upgrade', cardId: upg.id, targetSlot: slot });
155+
}
156+
}
157+
158+
actions.push({ type: 'end-turn' });
159+
return actions;
160+
}
161+
162+
interface Snapshot {
163+
turn: number;
164+
coins: number;
165+
reputation: number;
166+
score: number;
167+
gridIds: (string | null)[];
168+
gameResult: string;
169+
}
170+
171+
function snap(s: MainStreetState): Snapshot {
172+
return {
173+
turn: s.turn,
174+
coins: s.resourceBank.coins,
175+
reputation: s.resourceBank.reputation,
176+
score: computeScore(s),
177+
gridIds: s.streetGrid.map((b) => b?.id ?? null),
178+
gameResult: s.gameResult,
179+
};
180+
}
181+
182+
function playToEnd(s: MainStreetState): Snapshot[] {
183+
const out: Snapshot[] = [];
184+
while (s.gameResult === 'playing' && s.turn <= MAX_TURNS) {
185+
executeDayStart(s);
186+
for (const a of chooseActions(s)) {
187+
if (a.type === 'end-turn') break;
188+
try { executeAction(s, a); } catch { /* skip illegal */ }
189+
}
190+
processEndOfTurn(s);
191+
out.push(snap(s));
192+
if (s.gameResult !== 'playing') break;
193+
}
194+
return out;
195+
}
196+
197+
const store = new SaveLoadStore();
198+
199+
// Phase 1: play N turns then save
200+
const stateA = setupMainStreetGame({ seed: SEED });
201+
for (let t = 0; t < CHECKPOINT_AFTER && stateA.gameResult === 'playing' && stateA.turn <= MAX_TURNS; t++) {
202+
executeDayStart(stateA);
203+
for (const a of chooseActions(stateA)) {
204+
if (a.type === 'end-turn') break;
205+
try { executeAction(stateA, a); } catch { /* skip */ }
206+
}
207+
processEndOfTurn(stateA);
208+
}
209+
const checkpointTurn = stateA.turn;
210+
const checkpointCoins = stateA.resourceBank.coins;
211+
await saveTurnStartCheckpoint(store, stateA);
212+
213+
// Phase 2: continue to completion (path A)
214+
const pathA = playToEnd(stateA);
215+
216+
// Phase 3: restore and replay (path B)
217+
const restored = await loadTurnStartCheckpoint(store);
218+
expect(restored).not.toBeNull();
219+
expect(restored!.turn).toBe(checkpointTurn);
220+
expect(restored!.resourceBank.coins).toBe(checkpointCoins);
221+
222+
const pathB = playToEnd(restored!);
223+
224+
// Phase 4: assert identical outcomes
225+
expect(pathA.length).toBe(pathB.length);
226+
for (let i = 0; i < pathA.length; i++) {
227+
expect(pathB[i]).toEqual(pathA[i]);
228+
}
229+
230+
// Final state equivalence
231+
expect(snap(restored!)).toEqual(snap(stateA));
232+
});
233+
234+
it('campaign persistence round-trip within smoke scenario', async () => {
235+
const store = new SaveLoadStore();
236+
const campaign = createDefaultCampaignProgress();
237+
campaign.totalRuns = 5;
238+
campaign.totalWins = 2;
239+
campaign.persistentReputation = 14;
240+
campaign.highestScore = 42;
241+
campaign.unlockedTiers.push('tier-2');
242+
243+
await saveCampaignProgress(store, campaign);
244+
const loaded = await loadCampaignProgress(store);
245+
expect(loaded).not.toBeNull();
246+
expect(loaded!.totalRuns).toBe(5);
247+
expect(loaded!.totalWins).toBe(2);
248+
expect(loaded!.persistentReputation).toBe(14);
249+
expect(loaded!.highestScore).toBe(42);
250+
expect(loaded!.unlockedTiers).toContain('tier-2');
251+
252+
// Campaign data is isolated from run checkpoints
253+
const runSlots = await store.list('run-checkpoint', 'main-street');
254+
const campaignSlots = await store.list('campaign', 'main-street');
255+
expect(runSlots).toHaveLength(0);
256+
expect(campaignSlots.length).toBeGreaterThan(0);
257+
});
106258
});

0 commit comments

Comments
 (0)