|
1 | 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; |
2 | 2 | import { SaveLoadStore } from '../../src/core-engine'; |
3 | 3 | import { setupMainStreetGame } from '../../example-games/main-street/MainStreetState'; |
| 4 | +import type { MainStreetState } from '../../example-games/main-street/MainStreetState'; |
4 | 5 | import { |
5 | 6 | executeDayStart, |
6 | 7 | executeAction, |
7 | 8 | processEndOfTurn, |
| 9 | + computeScore, |
| 10 | + type PlayerAction, |
8 | 11 | } from '../../example-games/main-street/MainStreetEngine'; |
| 12 | +import { |
| 13 | + getAffordableBusinessCards, |
| 14 | + getAffordableUpgradeCards, |
| 15 | + getEmptySlots, |
| 16 | + canPurchaseEvent, |
| 17 | +} from '../../example-games/main-street/MainStreetMarket'; |
9 | 18 | import { |
10 | 19 | createDefaultCampaignProgress, |
11 | 20 | loadCampaignProgress, |
@@ -103,4 +112,147 @@ describe('Main Street save/load integration', () => { |
103 | 112 | const runSaves = await store.list('run-checkpoint', 'main-street'); |
104 | 113 | expect(runSaves).toHaveLength(0); |
105 | 114 | }); |
| 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 | + }); |
106 | 258 | }); |
0 commit comments