fix(sim): no arrivals at closing time, robust save import

This commit is contained in:
Victor Bodinaud committed 2026-10-08 12:43:14 +02:00
1 parent 73e5ee30b6
commit a677b9c61c
4 files changed
+51 -5

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ export function tick(state: GameState, catalog: Catalog, deltaMinutes: number):
for (let i = 0; i < deltaMinutes && state.minutes < CLOSE_MINUTES; i++) {
state.minutes += 1;
if (state.minutes === OPEN_MINUTES) events.push({ type: 'shop_opened' });
if (state.minutes > OPEN_MINUTES) {
if (state.minutes > OPEN_MINUTES && state.minutes < CLOSE_MINUTES) {
state.customerAccumulator += (customersPerHour(state.reputation) / 60) * (0.5 + random(state));
while (state.customerAccumulator >= 1) {
state.customerAccumulator -= 1;
+16 -3
View File
@@ -16,12 +16,25 @@ export function serialize(state: GameState): string {
return JSON.stringify(state);
}
const OBJECT_FIELDS = ['date', 'inventory', 'loyalty', 'day', 'prices', 'pendingOrders'] as const;
const NUMBER_FIELDS = ['cash', 'minutes', 'reputation', 'rngState', 'nextCustomerId', 'customerAccumulator'] as const;
export function deserialize(json: string): GameState {
const data = JSON.parse(json);
if (data.saveVersion !== 1) throw new Error(`Unsupported save version: ${data.saveVersion}`);
if (typeof data.cash !== 'number' || !data.date || !data.inventory || !data.loyalty) {
let data: unknown;
try {
data = JSON.parse(json);
} catch {
throw new Error('Corrupted save');
}
if (typeof data !== 'object' || data === null || Array.isArray(data)) throw new Error('Corrupted save');
const record = data as Record<string, unknown>;
if (record.saveVersion !== 1) throw new Error(`Unsupported save version: ${record.saveVersion}`);
for (const field of OBJECT_FIELDS) {
if (typeof record[field] !== 'object' || record[field] === null) throw new Error('Corrupted save');
}
for (const field of NUMBER_FIELDS) {
if (typeof record[field] !== 'number') throw new Error('Corrupted save');
}
return data as GameState;
}
+20
View File
@@ -24,6 +24,26 @@ describe('clock', () => {
expect(isOpen(s)).toBe(false);
});
it('never brings customers on the closing tick', () => {
for (let seed = 1; seed <= 20; seed++) {
const s = createNewGame(seed);
s.reputation = 100;
const arrivalMinutes: number[] = [];
let opened = 0;
let closed = 0;
while (s.minutes < CLOSE_MINUTES) {
const events = tick(s, catalog, 1);
if (events.some((e) => e.type === 'customer_arrived')) arrivalMinutes.push(s.minutes);
opened += events.filter((e) => e.type === 'shop_opened').length;
closed += events.filter((e) => e.type === 'shop_closed').length;
}
expect(arrivalMinutes.length).toBeGreaterThan(0);
expect(arrivalMinutes).not.toContain(CLOSE_MINUTES);
expect(opened).toBe(1);
expect(closed).toBe(1);
}
});
it('does nothing once closed', () => {
const s = createNewGame(1);
tick(s, catalog, 24 * 60);
+14 -1
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import { deserialize, loadGame, saveGame, SaveStorage, serialize } from '../../src/sim/save';
import { createNewGame } from '../../src/sim/state';
import { issueCard } from '../../src/sim/loyalty';
import { STARTING_CASH_CENTS } from '../../src/sim/economy';
function memoryStorage(): SaveStorage {
const data = new Map<string, string>();
@@ -25,6 +26,18 @@ describe('save', () => {
expect(() => deserialize('{"saveVersion":1}')).toThrow('Corrupted save');
});
it('rejects invalid JSON and non-object saves', () => {
expect(() => deserialize('{not json')).toThrow('Corrupted save');
expect(() => deserialize('null')).toThrow('Corrupted save');
expect(() => deserialize('[]')).toThrow('Corrupted save');
expect(() => deserialize('42')).toThrow('Corrupted save');
});
it('rejects saves with a missing field', () => {
const { day: _day, ...rest } = createNewGame(1);
expect(() => deserialize(JSON.stringify(rest))).toThrow('Corrupted save');
});
it('keeps the current and monthly saves apart', () => {
const storage = memoryStorage();
const s = createNewGame(1);
@@ -32,7 +45,7 @@ describe('save', () => {
s.cash = 1;
saveGame(storage, s, { monthly: false });
expect(loadGame(storage, 'current')!.cash).toBe(1);
expect(loadGame(storage, 'monthly')!.cash).not.toBe(1);
expect(loadGame(storage, 'monthly')!.cash).toBe(STARTING_CASH_CENTS);
});
it('returns null when nothing is saved', () => {