86 KiB
Simulation Core (Plan 1/2) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Construire le moteur de simulation headless du MVP (calendrier, argent F/€, catalogue GB 1990–1991, stock, économie, demande, clients, fidélité configurable, achat/rachat/échange, commandes, cycle de journée, sauvegarde) entièrement testé, sans aucun rendu.
Architecture: TypeScript pur dans src/sim/, zéro dépendance à Phaser. L'état du jeu est un objet GameState sérialisable en JSON, muté par des fonctions d'action. L'aléatoire passe par un PRNG seedé stocké dans l'état (déterministe, testable). Le plan 2 (Phaser) branchera le rendu sur ces actions.
Tech Stack: TypeScript (strict), Vite (plan 2), Vitest.
Spec : docs/superpowers/specs/2026-10-08-game-shop-sim-design.md
Conventions
- Montants : entiers en centimes d'euro. Les prix FR d'avant 2002 sont convertis via
francsToCents. L'affichage choisit F ou € selon la date. - Temps :
GameDate {year, month(1-12), day}+minutesdepuis minuit. 1 tick = 1 minute de jeu. - Tests dans
tests/sim/, un fichier par module. - Commande de test d'un fichier :
npx vitest run tests/sim/<module>.test.ts
File Structure
package.json, tsconfig.json, vitest.config.ts
src/data/games.json catalogue GB 28/09/1990 – fin 1991
src/sim/rng.ts PRNG seedé (mulberry32), weightedPick
src/sim/calendar.ts GameDate, dimanches, fin de mois, ère euro
src/sim/money.ts conversion F/€, formatage
src/sim/catalog.ts types Game, chargement, requêtes
src/sim/inventory.ts réserve + emplacements de rayon
src/sim/economy.ts prix de référence, grossiste, probabilité d'achat, loyer, priceOf
src/sim/demand.ts courbe de sortie, poids de demande
src/sim/loyalty.ts programme configurable, cartes, tampons, récompenses
src/sim/state.ts GameState, createNewGame, constantes horaires
src/sim/customers.ts génération de clients, choix en rayon
src/sim/orders.ts commandes grossiste, livraisons
src/sim/transactions.ts vente, rachat, échange, demandes
src/sim/clock.ts tick + événements
src/sim/day.ts fin de journée, dimanche, loyer, faillite
src/sim/save.ts sérialisation, stockage
src/sim/autoplay.ts joueur scripté (stabilité + debug)
src/sim/index.ts exports publics
tests/sim/helpers.ts catalogue de test, fabriques
tests/sim/*.test.ts
Task 1: Scaffold du projet
Files:
-
Create:
package.json,tsconfig.json,vitest.config.ts,tests/sim/smoke.test.ts -
Step 1: Créer
package.json
{
"name": "consolgames",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
}
}
- Step 2: Installer les dépendances
Run: npm install -D typescript vitest
Expected: node_modules/ créé, package.json contient devDependencies.
- Step 3: Créer
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"noUncheckedIndexedAccess": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "tests"]
}
- Step 4: Créer
vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
},
});
- Step 5: Créer
tests/sim/smoke.test.ts
import { describe, it, expect } from 'vitest';
describe('smoke', () => {
it('runs', () => {
expect(1 + 1).toBe(2);
});
});
- Step 6: Lancer les tests
Run: npx vitest run
Expected: 1 test PASS.
- Step 7: Commit
git add package.json package-lock.json tsconfig.json vitest.config.ts tests/sim/smoke.test.ts
git commit -m "chore: scaffold TypeScript + Vitest"
Task 2: PRNG seedé
Files:
-
Create:
src/sim/rng.ts -
Test:
tests/sim/rng.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import { random, randomInt, weightedPick } from '../../src/sim/rng';
describe('rng', () => {
it('is deterministic for a given seed', () => {
const a = { rngState: 42 };
const b = { rngState: 42 };
const seqA = [random(a), random(a), random(a)];
const seqB = [random(b), random(b), random(b)];
expect(seqA).toEqual(seqB);
});
it('returns values in [0, 1)', () => {
const h = { rngState: 7 };
for (let i = 0; i < 1000; i++) {
const v = random(h);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it('randomInt stays within inclusive bounds', () => {
const h = { rngState: 1 };
const seen = new Set<number>();
for (let i = 0; i < 1000; i++) {
const v = randomInt(h, 3, 5);
expect(v).toBeGreaterThanOrEqual(3);
expect(v).toBeLessThanOrEqual(5);
seen.add(v);
}
expect(seen.size).toBe(3);
});
it('weightedPick never picks zero-weight items', () => {
const h = { rngState: 3 };
const items = ['a', 'b', 'c'];
for (let i = 0; i < 500; i++) {
const v = weightedPick(h, items, (x) => (x === 'b' ? 0 : 1));
expect(v).not.toBe('b');
}
});
it('weightedPick returns undefined when all weights are zero', () => {
const h = { rngState: 3 };
expect(weightedPick(h, ['a'], () => 0)).toBeUndefined();
expect(weightedPick(h, [], () => 1)).toBeUndefined();
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/rng.test.ts
Expected: FAIL (module introuvable).
- Step 3: Implémenter
src/sim/rng.ts
export interface RngHolder {
rngState: number;
}
/** mulberry32 — mutates holder.rngState, returns a float in [0, 1). */
export function random(h: RngHolder): number {
let t = (h.rngState = (h.rngState + 0x6d2b79f5) | 0);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function randomInt(h: RngHolder, min: number, max: number): number {
return min + Math.floor(random(h) * (max - min + 1));
}
export function weightedPick<T>(
h: RngHolder,
items: readonly T[],
weight: (item: T) => number,
): T | undefined {
const weights = items.map((it) => Math.max(0, weight(it)));
const total = weights.reduce((sum, w) => sum + w, 0);
if (total <= 0) return undefined;
let r = random(h) * total;
let lastPositive: T | undefined;
for (let i = 0; i < items.length; i++) {
const w = weights[i];
if (w === 0) continue;
lastPositive = items[i];
if (r < w) return items[i];
r -= w;
}
return lastPositive;
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/rng.test.ts
Expected: 5 tests PASS.
- Step 5: Commit
git add src/sim/rng.ts tests/sim/rng.test.ts
git commit -m "feat(sim): seeded PRNG"
Task 3: Calendrier
Files:
-
Create:
src/sim/calendar.ts -
Test:
tests/sim/calendar.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import {
addDays, compareDates, dateToISO, daysBetween, isEuroEra,
isLastDayOfMonth, isSunday, parseISO, START_DATE,
} from '../../src/sim/calendar';
describe('calendar', () => {
it('starts on the European Game Boy launch (Friday 28/09/1990)', () => {
expect(START_DATE).toEqual({ year: 1990, month: 9, day: 28 });
expect(isSunday(START_DATE)).toBe(false);
});
it('detects Sundays', () => {
expect(isSunday({ year: 1990, month: 9, day: 30 })).toBe(true);
expect(isSunday({ year: 1990, month: 9, day: 29 })).toBe(false);
});
it('adds days across month and year boundaries', () => {
expect(addDays({ year: 1990, month: 12, day: 31 }, 1)).toEqual({ year: 1991, month: 1, day: 1 });
expect(addDays({ year: 1990, month: 9, day: 28 }, 3)).toEqual({ year: 1990, month: 10, day: 1 });
});
it('detects the last day of a month, including leap years', () => {
expect(isLastDayOfMonth({ year: 1992, month: 2, day: 29 })).toBe(true);
expect(isLastDayOfMonth({ year: 1992, month: 2, day: 28 })).toBe(false);
expect(isLastDayOfMonth({ year: 1990, month: 9, day: 30 })).toBe(true);
});
it('switches to the euro on 01/01/2002', () => {
expect(isEuroEra({ year: 2001, month: 12, day: 31 })).toBe(false);
expect(isEuroEra({ year: 2002, month: 1, day: 1 })).toBe(true);
});
it('computes days between dates', () => {
expect(daysBetween({ year: 1990, month: 9, day: 28 }, { year: 1990, month: 10, day: 28 })).toBe(30);
expect(daysBetween({ year: 1990, month: 10, day: 28 }, { year: 1990, month: 9, day: 28 })).toBe(-30);
});
it('round-trips ISO strings and compares dates', () => {
expect(dateToISO(parseISO('1990-09-28'))).toBe('1990-09-28');
expect(compareDates(parseISO('1990-09-28'), parseISO('1991-01-01'))).toBeLessThan(0);
expect(compareDates(parseISO('1991-01-01'), parseISO('1991-01-01'))).toBe(0);
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/calendar.test.ts
Expected: FAIL (module introuvable).
- Step 3: Implémenter
src/sim/calendar.ts
export interface GameDate {
year: number;
month: number; // 1-12
day: number;
}
const MS_PER_DAY = 86_400_000;
export const START_DATE: GameDate = { year: 1990, month: 9, day: 28 };
export const EURO_DATE: GameDate = { year: 2002, month: 1, day: 1 };
function toUTC(d: GameDate): number {
return Date.UTC(d.year, d.month - 1, d.day);
}
function fromUTC(ms: number): GameDate {
const js = new Date(ms);
return { year: js.getUTCFullYear(), month: js.getUTCMonth() + 1, day: js.getUTCDate() };
}
export function addDays(d: GameDate, n: number): GameDate {
return fromUTC(toUTC(d) + n * MS_PER_DAY);
}
export function daysBetween(a: GameDate, b: GameDate): number {
return Math.round((toUTC(b) - toUTC(a)) / MS_PER_DAY);
}
export function compareDates(a: GameDate, b: GameDate): number {
return toUTC(a) - toUTC(b);
}
export function isSunday(d: GameDate): boolean {
return new Date(toUTC(d)).getUTCDay() === 0;
}
export function isLastDayOfMonth(d: GameDate): boolean {
return addDays(d, 1).month !== d.month;
}
export function isEuroEra(d: GameDate): boolean {
return compareDates(d, EURO_DATE) >= 0;
}
export function dateToISO(d: GameDate): string {
const mm = String(d.month).padStart(2, '0');
const dd = String(d.day).padStart(2, '0');
return `${d.year}-${mm}-${dd}`;
}
export function parseISO(s: string): GameDate {
const [year, month, day] = s.split('-').map(Number);
return { year, month, day };
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/calendar.test.ts
Expected: 7 tests PASS.
- Step 5: Commit
git add src/sim/calendar.ts tests/sim/calendar.test.ts
git commit -m "feat(sim): calendar"
Task 4: Argent (francs / euros)
Files:
-
Create:
src/sim/money.ts -
Test:
tests/sim/money.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import { centsToFrancs, formatMoney, francsToCents } from '../../src/sim/money';
describe('money', () => {
it('converts francs to euro cents at the fixed rate', () => {
expect(francsToCents(249)).toBe(3796);
expect(francsToCents(199)).toBe(3034);
expect(francsToCents(0)).toBe(0);
});
it('converts cents back to francs', () => {
expect(centsToFrancs(3796)).toBeCloseTo(249, 1);
});
it('formats in francs before 2002', () => {
expect(formatMoney(3796, { year: 1990, month: 9, day: 28 })).toBe('249,00 F');
});
it('formats in euros from 2002', () => {
expect(formatMoney(3796, { year: 2002, month: 1, day: 1 })).toBe('37,96 €');
});
it('formats negative amounts', () => {
expect(formatMoney(-3796, { year: 2002, month: 1, day: 1 })).toBe('-37,96 €');
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/money.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/money.ts
import { GameDate, isEuroEra } from './calendar';
export const FRANCS_PER_EURO = 6.55957;
export function francsToCents(francs: number): number {
return Math.round((francs / FRANCS_PER_EURO) * 100);
}
export function centsToFrancs(cents: number): number {
return (cents / 100) * FRANCS_PER_EURO;
}
export function formatMoney(cents: number, date: GameDate): string {
const euro = isEuroEra(date);
const value = euro ? cents / 100 : centsToFrancs(cents);
const text = value.toFixed(2).replace('.', ',');
return euro ? `${text} €` : `${text} F`;
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/money.test.ts
Expected: 5 tests PASS.
- Step 5: Commit
git add src/sim/money.ts tests/sim/money.test.ts
git commit -m "feat(sim): franc/euro money helpers"
Task 5: Catalogue + données GB 1990–1991
Files:
- Create:
src/sim/catalog.ts,src/data/games.json,tests/sim/helpers.ts - Test:
tests/sim/catalog.test.ts
Les dates et prix FR de cette époque sont mal documentés : tout ce qui n'est pas vérifié porte "uncertain": true.
- Step 1: Créer
src/data/games.json
[
{ "id": "tetris", "title": "Tetris", "console": "GB", "releaseDate": "1990-09-28", "launchPriceFrancs": 199, "publisher": "Nintendo", "genre": "Puzzle", "basePopularity": 95 },
{ "id": "super-mario-land", "title": "Super Mario Land", "console": "GB", "releaseDate": "1990-09-28", "launchPriceFrancs": 249, "publisher": "Nintendo", "genre": "Plateforme", "basePopularity": 95 },
{ "id": "tennis", "title": "Tennis", "console": "GB", "releaseDate": "1990-09-28", "launchPriceFrancs": 199, "publisher": "Nintendo", "genre": "Sport", "basePopularity": 50, "uncertain": true },
{ "id": "alleyway", "title": "Alleyway", "console": "GB", "releaseDate": "1990-09-28", "launchPriceFrancs": 199, "publisher": "Nintendo", "genre": "Casse-briques", "basePopularity": 40, "uncertain": true },
{ "id": "baseball", "title": "Baseball", "console": "GB", "releaseDate": "1990-09-28", "launchPriceFrancs": 199, "publisher": "Nintendo", "genre": "Sport", "basePopularity": 30, "uncertain": true },
{ "id": "golf", "title": "Golf", "console": "GB", "releaseDate": "1990-11-15", "launchPriceFrancs": 199, "publisher": "Nintendo", "genre": "Sport", "basePopularity": 45, "uncertain": true },
{ "id": "kwirk", "title": "Kwirk", "console": "GB", "releaseDate": "1990-10-15", "launchPriceFrancs": 199, "publisher": "Acclaim", "genre": "Puzzle", "basePopularity": 30, "uncertain": true },
{ "id": "bugs-bunny-crazy-castle", "title": "The Bugs Bunny Crazy Castle", "console": "GB", "releaseDate": "1990-10-15", "launchPriceFrancs": 249, "publisher": "Kemco", "genre": "Puzzle", "basePopularity": 45, "uncertain": true },
{ "id": "castlevania-the-adventure", "title": "Castlevania: The Adventure", "console": "GB", "releaseDate": "1990-11-15", "launchPriceFrancs": 249, "publisher": "Konami", "genre": "Action", "basePopularity": 50, "uncertain": true },
{ "id": "batman", "title": "Batman", "console": "GB", "releaseDate": "1990-11-15", "launchPriceFrancs": 249, "publisher": "Sunsoft", "genre": "Action", "basePopularity": 60, "uncertain": true },
{ "id": "solar-striker", "title": "Solar Striker", "console": "GB", "releaseDate": "1990-11-15", "launchPriceFrancs": 199, "publisher": "Nintendo", "genre": "Shoot'em up", "basePopularity": 40, "uncertain": true },
{ "id": "dr-mario", "title": "Dr. Mario", "console": "GB", "releaseDate": "1990-12-01", "launchPriceFrancs": 199, "publisher": "Nintendo", "genre": "Puzzle", "basePopularity": 60, "uncertain": true },
{ "id": "gargoyles-quest", "title": "Gargoyle's Quest", "console": "GB", "releaseDate": "1990-12-01", "launchPriceFrancs": 249, "publisher": "Capcom", "genre": "Action", "basePopularity": 55, "uncertain": true },
{ "id": "double-dragon", "title": "Double Dragon", "console": "GB", "releaseDate": "1990-12-01", "launchPriceFrancs": 249, "publisher": "Tradewest", "genre": "Beat'em all", "basePopularity": 55, "uncertain": true },
{ "id": "turtles-fall-of-the-foot-clan", "title": "Teenage Mutant Hero Turtles: Fall of the Foot Clan", "console": "GB", "releaseDate": "1991-02-15", "launchPriceFrancs": 249, "publisher": "Konami", "genre": "Action", "basePopularity": 75, "uncertain": true },
{ "id": "nemesis", "title": "Nemesis", "console": "GB", "releaseDate": "1991-03-15", "launchPriceFrancs": 249, "publisher": "Konami", "genre": "Shoot'em up", "basePopularity": 50, "uncertain": true },
{ "id": "duck-tales", "title": "La Bande à Picsou", "console": "GB", "releaseDate": "1991-04-15", "launchPriceFrancs": 249, "publisher": "Capcom", "genre": "Plateforme", "basePopularity": 60, "uncertain": true },
{ "id": "qix", "title": "Qix", "console": "GB", "releaseDate": "1991-05-15", "launchPriceFrancs": 199, "publisher": "Nintendo", "genre": "Puzzle", "basePopularity": 35, "uncertain": true },
{ "id": "f1-race", "title": "F-1 Race", "console": "GB", "releaseDate": "1991-06-15", "launchPriceFrancs": 249, "publisher": "Nintendo", "genre": "Course", "basePopularity": 45, "uncertain": true },
{ "id": "bubble-bobble", "title": "Bubble Bobble", "console": "GB", "releaseDate": "1991-07-15", "launchPriceFrancs": 249, "publisher": "Taito", "genre": "Plateforme", "basePopularity": 50, "uncertain": true },
{ "id": "the-amazing-spider-man", "title": "The Amazing Spider-Man", "console": "GB", "releaseDate": "1991-09-15", "launchPriceFrancs": 249, "publisher": "LJN", "genre": "Action", "basePopularity": 50, "uncertain": true },
{ "id": "radar-mission", "title": "Radar Mission", "console": "GB", "releaseDate": "1991-10-15", "launchPriceFrancs": 199, "publisher": "Nintendo", "genre": "Stratégie", "basePopularity": 30, "uncertain": true }
]
- Step 2: Créer
tests/sim/helpers.ts(fixtures partagées par les tâches suivantes)
import { createCatalog, Game } from '../../src/sim/catalog';
export const TEST_GAMES: Game[] = [
{ id: 'tetris', title: 'Tetris', console: 'GB', releaseDate: '1990-09-28', launchPriceCents: 3034, publisher: 'Nintendo', genre: 'Puzzle', basePopularity: 90 },
{ id: 'sml', title: 'Super Mario Land', console: 'GB', releaseDate: '1990-09-28', launchPriceCents: 3796, publisher: 'Nintendo', genre: 'Plateforme', basePopularity: 95 },
{ id: 'future', title: 'Future Game', console: 'GB', releaseDate: '1991-06-01', launchPriceCents: 3796, publisher: 'Test', genre: 'Action', basePopularity: 50 },
];
export function testCatalog() {
return createCatalog(TEST_GAMES);
}
- Step 3: Écrire le test
tests/sim/catalog.test.ts
import { describe, it, expect } from 'vitest';
import { availableGames, createCatalog, getGame, loadDefaultCatalog } from '../../src/sim/catalog';
import { START_DATE, parseISO } from '../../src/sim/calendar';
import { TEST_GAMES, testCatalog } from './helpers';
describe('catalog', () => {
it('indexes games by id', () => {
expect(getGame(testCatalog(), 'sml').title).toBe('Super Mario Land');
});
it('throws on unknown id', () => {
expect(() => getGame(testCatalog(), 'nope')).toThrow('Unknown game: nope');
});
it('rejects duplicate ids', () => {
expect(() => createCatalog([TEST_GAMES[0], TEST_GAMES[0]])).toThrow('Duplicate game id: tetris');
});
it('only lists released games', () => {
const ids = availableGames(testCatalog(), START_DATE).map((g) => g.id);
expect(ids).toEqual(['tetris', 'sml']);
const later = availableGames(testCatalog(), parseISO('1991-06-01')).map((g) => g.id);
expect(later).toContain('future');
});
it('loads the default GB catalog with francs converted to cents', () => {
const catalog = loadDefaultCatalog();
expect(catalog.games.length).toBeGreaterThanOrEqual(20);
expect(getGame(catalog, 'super-mario-land').launchPriceCents).toBe(3796);
for (const g of catalog.games) {
expect(g.releaseDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(g.basePopularity).toBeGreaterThanOrEqual(0);
expect(g.basePopularity).toBeLessThanOrEqual(100);
}
expect(availableGames(catalog, START_DATE).map((g) => g.id)).toContain('tetris');
});
});
- Step 4: Vérifier l'échec
Run: npx vitest run tests/sim/catalog.test.ts
Expected: FAIL.
- Step 5: Implémenter
src/sim/catalog.ts
import { compareDates, GameDate, parseISO } from './calendar';
import { francsToCents } from './money';
import rawGames from '../data/games.json';
export type ConsoleId = 'GB' | 'GBC' | 'GBA';
export interface Game {
id: string;
title: string;
console: ConsoleId;
releaseDate: string; // YYYY-MM-DD, sortie FR
launchPriceCents: number;
publisher: string;
genre: string;
basePopularity: number; // 0-100
cover?: string;
uncertain?: boolean;
}
export interface RawGame extends Omit<Game, 'launchPriceCents'> {
launchPriceFrancs: number;
}
export interface Catalog {
games: Game[];
byId: Map<string, Game>;
}
export function createCatalog(games: Game[]): Catalog {
const byId = new Map<string, Game>();
for (const g of games) {
if (byId.has(g.id)) throw new Error(`Duplicate game id: ${g.id}`);
byId.set(g.id, g);
}
return { games, byId };
}
export function loadGames(raw: RawGame[]): Game[] {
return raw.map(({ launchPriceFrancs, ...rest }) => ({
...rest,
launchPriceCents: francsToCents(launchPriceFrancs),
}));
}
export function loadDefaultCatalog(): Catalog {
return createCatalog(loadGames(rawGames as RawGame[]));
}
export function getGame(catalog: Catalog, id: string): Game {
const game = catalog.byId.get(id);
if (!game) throw new Error(`Unknown game: ${id}`);
return game;
}
export function availableGames(catalog: Catalog, date: GameDate): Game[] {
return catalog.games.filter((g) => compareDates(parseISO(g.releaseDate), date) <= 0);
}
- Step 6: Vérifier le succès
Run: npx vitest run tests/sim/catalog.test.ts
Expected: 5 tests PASS.
- Step 7: Commit
git add src/sim/catalog.ts src/data/games.json tests/sim/helpers.ts tests/sim/catalog.test.ts
git commit -m "feat(sim): game catalog with GB 1990-1991 data"
Task 6: Stock (réserve + rayons)
Files:
-
Create:
src/sim/inventory.ts -
Test:
tests/sim/inventory.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import {
addToBackroom, backroomQty, createInventory, getSlot, itemKey, parseItemKey,
shelfItems, SLOT_CAPACITY, stockSlot, takeFromSlot, totalQty,
} from '../../src/sim/inventory';
const tetrisNew = { gameId: 'tetris', condition: 'new' as const };
const smlLoose = { gameId: 'sml', condition: 'loose' as const };
describe('inventory', () => {
it('round-trips item keys', () => {
expect(parseItemKey(itemKey(smlLoose))).toEqual(smlLoose);
});
it('creates empty slots', () => {
const inv = createInventory(3);
expect(inv.slots.map((s) => s.id)).toEqual(['slot-0', 'slot-1', 'slot-2']);
expect(shelfItems(inv)).toEqual([]);
});
it('adds to the backroom', () => {
const inv = createInventory(1);
addToBackroom(inv, tetrisNew, 3);
addToBackroom(inv, tetrisNew, 2);
expect(backroomQty(inv, tetrisNew)).toBe(5);
});
it('stocks a slot up to capacity from the backroom', () => {
const inv = createInventory(1);
addToBackroom(inv, tetrisNew, SLOT_CAPACITY + 2);
const moved = stockSlot(inv, 'slot-0', tetrisNew, 100);
expect(moved).toBe(SLOT_CAPACITY);
expect(getSlot(inv, 'slot-0')).toEqual({ id: 'slot-0', item: tetrisNew, qty: SLOT_CAPACITY });
expect(backroomQty(inv, tetrisNew)).toBe(2);
expect(totalQty(inv, tetrisNew)).toBe(SLOT_CAPACITY + 2);
});
it('moves nothing when the backroom is empty', () => {
const inv = createInventory(1);
expect(stockSlot(inv, 'slot-0', tetrisNew, 3)).toBe(0);
expect(getSlot(inv, 'slot-0').item).toBeNull();
});
it('refuses mixing items in one slot', () => {
const inv = createInventory(1);
addToBackroom(inv, tetrisNew, 1);
addToBackroom(inv, smlLoose, 1);
stockSlot(inv, 'slot-0', tetrisNew, 1);
expect(() => stockSlot(inv, 'slot-0', smlLoose, 1)).toThrow('Slot slot-0 holds another item');
});
it('takes items from a slot and clears it when empty', () => {
const inv = createInventory(1);
addToBackroom(inv, tetrisNew, 2);
stockSlot(inv, 'slot-0', tetrisNew, 2);
expect(takeFromSlot(inv, 'slot-0')).toEqual(tetrisNew);
expect(getSlot(inv, 'slot-0').qty).toBe(1);
takeFromSlot(inv, 'slot-0');
expect(getSlot(inv, 'slot-0')).toEqual({ id: 'slot-0', item: null, qty: 0 });
expect(() => takeFromSlot(inv, 'slot-0')).toThrow('Slot slot-0 is empty');
});
it('throws on unknown slot', () => {
expect(() => getSlot(createInventory(1), 'slot-9')).toThrow('Unknown slot: slot-9');
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/inventory.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/inventory.ts
export type Condition = 'new' | 'complete' | 'no_manual' | 'loose';
export interface ItemRef {
gameId: string;
condition: Condition;
}
export interface ShelfSlot {
id: string;
item: ItemRef | null;
qty: number;
}
export interface Inventory {
backroom: Record<string, number>;
slots: ShelfSlot[];
}
export const SLOT_CAPACITY = 5;
export function itemKey(item: ItemRef): string {
return `${item.gameId}|${item.condition}`;
}
export function parseItemKey(key: string): ItemRef {
const [gameId, condition] = key.split('|');
return { gameId, condition: condition as Condition };
}
export function createInventory(slotCount: number): Inventory {
const slots: ShelfSlot[] = [];
for (let i = 0; i < slotCount; i++) slots.push({ id: `slot-${i}`, item: null, qty: 0 });
return { backroom: {}, slots };
}
export function addToBackroom(inv: Inventory, item: ItemRef, qty: number): void {
const key = itemKey(item);
inv.backroom[key] = (inv.backroom[key] ?? 0) + qty;
}
export function backroomQty(inv: Inventory, item: ItemRef): number {
return inv.backroom[itemKey(item)] ?? 0;
}
export function getSlot(inv: Inventory, slotId: string): ShelfSlot {
const slot = inv.slots.find((s) => s.id === slotId);
if (!slot) throw new Error(`Unknown slot: ${slotId}`);
return slot;
}
/** Moves up to `qty` items from the backroom into the slot. Returns the quantity moved. */
export function stockSlot(inv: Inventory, slotId: string, item: ItemRef, qty: number): number {
const slot = getSlot(inv, slotId);
if (slot.item && itemKey(slot.item) !== itemKey(item)) {
throw new Error(`Slot ${slotId} holds another item`);
}
const key = itemKey(item);
const moved = Math.min(qty, SLOT_CAPACITY - slot.qty, inv.backroom[key] ?? 0);
if (moved <= 0) return 0;
slot.item = item;
slot.qty += moved;
inv.backroom[key] -= moved;
if (inv.backroom[key] === 0) delete inv.backroom[key];
return moved;
}
export function takeFromSlot(inv: Inventory, slotId: string): ItemRef {
const slot = getSlot(inv, slotId);
if (!slot.item || slot.qty === 0) throw new Error(`Slot ${slotId} is empty`);
const item = slot.item;
slot.qty -= 1;
if (slot.qty === 0) slot.item = null;
return item;
}
export function shelfItems(inv: Inventory): ShelfSlot[] {
return inv.slots.filter((s) => s.item !== null && s.qty > 0);
}
export function totalQty(inv: Inventory, item: ItemRef): number {
const key = itemKey(item);
const onShelves = inv.slots
.filter((s) => s.item && itemKey(s.item) === key)
.reduce((sum, s) => sum + s.qty, 0);
return backroomQty(inv, item) + onShelves;
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/inventory.test.ts
Expected: 8 tests PASS.
- Step 5: Commit
git add src/sim/inventory.ts tests/sim/inventory.test.ts
git commit -m "feat(sim): inventory with backroom and shelf slots"
Task 7: Économie
Files:
-
Create:
src/sim/economy.ts -
Test:
tests/sim/economy.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import {
customerMinimumAsk, MONTHLY_RENT_CENTS, priceOf, purchaseProbability,
referencePrice, wholesaleCost,
} from '../../src/sim/economy';
import { getGame } from '../../src/sim/catalog';
import { testCatalog } from './helpers';
const catalog = testCatalog();
const sml = getGame(catalog, 'sml'); // 3796 cents
describe('economy', () => {
it('buys from the wholesaler at 65% of launch price', () => {
expect(wholesaleCost(sml)).toBe(2467);
});
it('derives used reference prices from the condition', () => {
expect(referencePrice(sml, 'new')).toBe(3796);
expect(referencePrice(sml, 'complete')).toBe(2278);
expect(referencePrice(sml, 'no_manual')).toBe(1898);
expect(referencePrice(sml, 'loose')).toBe(1518);
});
it('maps price ratio to purchase probability', () => {
expect(purchaseProbability(1000, 1000)).toBeCloseTo(0.5);
expect(purchaseProbability(500, 1000)).toBe(1);
expect(purchaseProbability(1500, 1000)).toBe(0);
expect(purchaseProbability(3000, 1000)).toBe(0);
});
it('computes what a customer accepts for a buy-back', () => {
expect(customerMinimumAsk(sml, 'complete', 'cash')).toBe(797);
expect(customerMinimumAsk(sml, 'complete', 'trade')).toBe(1139);
});
it('uses the player price, else the reference price', () => {
const item = { gameId: 'sml', condition: 'new' as const };
expect(priceOf({}, catalog, item)).toBe(3796);
expect(priceOf({ 'sml|new': 3500 }, catalog, item)).toBe(3500);
});
it('charges 6000 F of rent per month', () => {
expect(MONTHLY_RENT_CENTS).toBe(91469);
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/economy.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/economy.ts
import { Catalog, Game, getGame } from './catalog';
import { Condition, ItemRef, itemKey } from './inventory';
import { francsToCents } from './money';
export const WHOLESALE_RATE = 0.65;
export const USED_FACTOR: Record<Exclude<Condition, 'new'>, number> = {
complete: 0.6,
no_manual: 0.5,
loose: 0.4,
};
export const CASH_BUYBACK_RATE = 0.35;
export const TRADE_BUYBACK_RATE = 0.5;
export const MONTHLY_RENT_CENTS = francsToCents(6000);
export const STARTING_CASH_CENTS = francsToCents(30000);
export type BuyBackMode = 'cash' | 'trade';
export function wholesaleCost(game: Game): number {
return Math.round(game.launchPriceCents * WHOLESALE_RATE);
}
export function referencePrice(game: Game, condition: Condition): number {
if (condition === 'new') return game.launchPriceCents;
return Math.round(game.launchPriceCents * USED_FACTOR[condition]);
}
/** 0.5 at the reference price, 1 at half price or less, 0 at 150% or more. */
export function purchaseProbability(priceCents: number, referenceCents: number): number {
const ratio = priceCents / referenceCents;
return Math.min(1, Math.max(0, 1.5 - ratio));
}
export function customerMinimumAsk(game: Game, condition: Condition, mode: BuyBackMode): number {
const rate = mode === 'cash' ? CASH_BUYBACK_RATE : TRADE_BUYBACK_RATE;
return Math.round(referencePrice(game, condition) * rate);
}
export function priceOf(prices: Record<string, number>, catalog: Catalog, item: ItemRef): number {
return prices[itemKey(item)] ?? referencePrice(getGame(catalog, item.gameId), item.condition);
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/economy.test.ts
Expected: 6 tests PASS.
- Step 5: Commit
git add src/sim/economy.ts tests/sim/economy.test.ts
git commit -m "feat(sim): economy rules"
Task 8: Demande
Files:
-
Create:
src/sim/demand.ts -
Test:
tests/sim/demand.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import { demandWeight, releaseCurve } from '../../src/sim/demand';
import { getGame } from '../../src/sim/catalog';
import { START_DATE } from '../../src/sim/calendar';
import { testCatalog } from './helpers';
describe('demand', () => {
it('is zero before release and peaks at launch', () => {
expect(releaseCurve(-1)).toBe(0);
expect(releaseCurve(0)).toBe(1.5);
expect(releaseCurve(29)).toBe(1.5);
});
it('decays to 0.5 after two years and floors at 0.3', () => {
expect(releaseCurve(730)).toBeCloseTo(0.5);
expect(releaseCurve(5000)).toBeCloseTo(0.3);
});
it('never increases over time after release', () => {
let prev = releaseCurve(0);
for (let d = 10; d <= 6000; d += 10) {
const v = releaseCurve(d);
expect(v).toBeLessThanOrEqual(prev + 1e-9);
prev = v;
}
});
it('scales by base popularity', () => {
const catalog = testCatalog();
expect(demandWeight(getGame(catalog, 'sml'), START_DATE)).toBeCloseTo(0.95 * 1.5);
expect(demandWeight(getGame(catalog, 'future'), START_DATE)).toBe(0);
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/demand.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/demand.ts
import { daysBetween, GameDate, parseISO } from './calendar';
import { Game } from './catalog';
export function releaseCurve(daysSinceRelease: number): number {
const d = daysSinceRelease;
if (d < 0) return 0;
if (d < 30) return 1.5;
if (d < 730) return 1.5 - ((d - 30) / 700) * 1.0;
return Math.max(0.3, 0.5 - ((d - 730) / 3650) * 0.2);
}
export function demandWeight(game: Game, date: GameDate): number {
const days = daysBetween(parseISO(game.releaseDate), date);
return (game.basePopularity / 100) * releaseCurve(days);
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/demand.test.ts
Expected: 4 tests PASS.
- Step 5: Commit
git add src/sim/demand.ts tests/sim/demand.test.ts
git commit -m "feat(sim): release demand curve"
Task 9: Fidélité configurable
Files:
-
Create:
src/sim/loyalty.ts -
Test:
tests/sim/loyalty.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import {
addStamp, createLoyaltyState, currentProgram, generosity, issueCard,
joinProbability, returnVisitMultiplier, setProgram, StampTrack, validateTracks,
} from '../../src/sim/loyalty';
describe('loyalty', () => {
it('starts with 10 purchase stamps for a used game up to 10 €', () => {
const l = createLoyaltyState();
expect(currentProgram(l)).toEqual({
version: 1,
tracks: [{ stampType: 'buy_new', slots: 10, reward: { kind: 'free_used_game', maxValueCents: 1000 } }],
});
expect(generosity(currentProgram(l))).toBe(100);
expect(returnVisitMultiplier(currentProgram(l))).toBe(2);
expect(joinProbability(currentProgram(l))).toBeCloseTo(0.25);
});
it('issues numbered cards on the current program', () => {
const l = createLoyaltyState();
const card = issueCard(l);
expect(card).toEqual({ id: 'C0001', programVersion: 1, stamps: [0], pendingRewards: [], voucherCents: 0 });
expect(issueCard(l).id).toBe('C0002');
});
it('earns the reward on the last stamp and resets the track', () => {
const l = createLoyaltyState();
const card = issueCard(l);
for (let i = 0; i < 9; i++) expect(addStamp(l, card.id, 'buy_new')).toEqual([]);
expect(addStamp(l, card.id, 'buy_new')).toEqual([{ kind: 'free_used_game', maxValueCents: 1000 }]);
expect(card.stamps).toEqual([0]);
expect(card.pendingRewards).toHaveLength(1);
});
it('ignores stamp types the card does not track', () => {
const l = createLoyaltyState();
const card = issueCard(l);
expect(addStamp(l, card.id, 'trade')).toEqual([]);
expect(card.stamps).toEqual([0]);
});
it('supports two independent stamp tracks', () => {
const l = createLoyaltyState();
setProgram(l, [
{ stampType: 'buy_new', slots: 3, reward: { kind: 'discount_percent', percent: 20 } },
{ stampType: 'trade', slots: 3, reward: { kind: 'free_trade' } },
]);
const card = issueCard(l);
addStamp(l, card.id, 'buy_new');
addStamp(l, card.id, 'trade');
addStamp(l, card.id, 'trade');
expect(card.stamps).toEqual([1, 2]);
expect(addStamp(l, card.id, 'trade')).toEqual([{ kind: 'free_trade' }]);
});
it('turns voucher rewards into card credit', () => {
const l = createLoyaltyState();
setProgram(l, [{ stampType: 'buy_used', slots: 3, reward: { kind: 'voucher', valueCents: 500 } }]);
const card = issueCard(l);
for (let i = 0; i < 3; i++) addStamp(l, card.id, 'buy_used');
expect(card.voucherCents).toBe(500);
expect(card.pendingRewards).toEqual([]);
});
it('validates tracks', () => {
const reward = { kind: 'free_trade' } as const;
const t = (stampType: StampTrack['stampType'], slots: number): StampTrack => ({ stampType, slots, reward });
expect(validateTracks([])).toBe('1 ou 2 types de tampons');
expect(validateTracks([t('buy_new', 5), t('trade', 5), t('buy_back', 5)])).toBe('1 ou 2 types de tampons');
expect(validateTracks([t('buy_new', 5), t('buy_new', 5)])).toBe('Types de tampons en double');
expect(validateTracks([t('buy_new', 2)])).toBe('Entre 3 et 20 cases par type');
expect(validateTracks([t('buy_new', 21)])).toBe('Entre 3 et 20 cases par type');
expect(validateTracks([t('buy_new', 3)])).toBeNull();
expect(() => setProgram(createLoyaltyState(), [])).toThrow('1 ou 2 types de tampons');
});
it('keeps existing cards on the rules they were issued with', () => {
const l = createLoyaltyState();
const oldCard = issueCard(l);
setProgram(l, [{ stampType: 'buy_new', slots: 3, reward: { kind: 'voucher', valueCents: 500 } }]);
for (let i = 0; i < 3; i++) addStamp(l, oldCard.id, 'buy_new');
expect(oldCard.stamps).toEqual([3]);
expect(oldCard.voucherCents).toBe(0);
expect(issueCard(l).programVersion).toBe(2);
});
it('penalizes reputation when the program becomes less generous', () => {
const l = createLoyaltyState();
const lessGenerous = setProgram(l, [{ stampType: 'buy_new', slots: 20, reward: { kind: 'voucher', valueCents: 500 } }]);
expect(lessGenerous.reputationDelta).toBe(-2);
const moreGenerous = setProgram(l, [{ stampType: 'buy_new', slots: 5, reward: { kind: 'voucher', valueCents: 1000 } }]);
expect(moreGenerous.reputationDelta).toBe(0);
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/loyalty.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/loyalty.ts
export type StampType = 'buy_new' | 'buy_used' | 'trade' | 'buy_back';
export type Reward =
| { kind: 'free_trade' }
| { kind: 'free_used_game'; maxValueCents: number }
| { kind: 'discount_percent'; percent: number }
| { kind: 'voucher'; valueCents: number };
export interface StampTrack {
stampType: StampType;
slots: number;
reward: Reward;
}
export interface LoyaltyProgram {
version: number;
tracks: StampTrack[];
}
export interface LoyaltyCard {
id: string;
programVersion: number;
stamps: number[]; // one counter per track of its program
pendingRewards: Reward[];
voucherCents: number;
}
export interface LoyaltyState {
programs: LoyaltyProgram[];
cards: Record<string, LoyaltyCard>;
nextCardNumber: number;
}
export const DEFAULT_TRACKS: StampTrack[] = [
{ stampType: 'buy_new', slots: 10, reward: { kind: 'free_used_game', maxValueCents: 1000 } },
];
/** Estimated cost of a reward, used to rate how generous a program is. */
const FREE_TRADE_VALUE_CENTS = 800;
const AVERAGE_BASKET_CENTS = 3000;
export function createLoyaltyState(): LoyaltyState {
return { programs: [{ version: 1, tracks: DEFAULT_TRACKS }], cards: {}, nextCardNumber: 1 };
}
export function currentProgram(l: LoyaltyState): LoyaltyProgram {
return l.programs[l.programs.length - 1];
}
export function getProgram(l: LoyaltyState, version: number): LoyaltyProgram {
const program = l.programs.find((p) => p.version === version);
if (!program) throw new Error(`Unknown loyalty program version: ${version}`);
return program;
}
export function validateTracks(tracks: StampTrack[]): string | null {
if (tracks.length < 1 || tracks.length > 2) return '1 ou 2 types de tampons';
if (new Set(tracks.map((t) => t.stampType)).size !== tracks.length) return 'Types de tampons en double';
if (tracks.some((t) => t.slots < 3 || t.slots > 20)) return 'Entre 3 et 20 cases par type';
return null;
}
export function rewardValueCents(r: Reward): number {
switch (r.kind) {
case 'free_trade': return FREE_TRADE_VALUE_CENTS;
case 'free_used_game': return r.maxValueCents;
case 'discount_percent': return (AVERAGE_BASKET_CENTS * r.percent) / 100;
case 'voucher': return r.valueCents;
}
}
/** Reward value given back per stamp, in cents. */
export function generosity(program: LoyaltyProgram): number {
return program.tracks.reduce((sum, t) => sum + rewardValueCents(t.reward) / t.slots, 0);
}
export function returnVisitMultiplier(program: LoyaltyProgram): number {
return 1 + Math.min(generosity(program) / 100, 1);
}
export function joinProbability(program: LoyaltyProgram): number {
return Math.min(0.15 + generosity(program) / 1000, 0.6);
}
export function setProgram(l: LoyaltyState, tracks: StampTrack[]): { reputationDelta: number } {
const error = validateTracks(tracks);
if (error) throw new Error(error);
const previous = currentProgram(l);
const next: LoyaltyProgram = { version: previous.version + 1, tracks };
l.programs.push(next);
return { reputationDelta: generosity(next) < generosity(previous) ? -2 : 0 };
}
export function issueCard(l: LoyaltyState): LoyaltyCard {
const program = currentProgram(l);
const card: LoyaltyCard = {
id: `C${String(l.nextCardNumber).padStart(4, '0')}`,
programVersion: program.version,
stamps: program.tracks.map(() => 0),
pendingRewards: [],
voucherCents: 0,
};
l.nextCardNumber += 1;
l.cards[card.id] = card;
return card;
}
/** Stamps every track of the card matching `type`. Returns the rewards earned. */
export function addStamp(l: LoyaltyState, cardId: string, type: StampType): Reward[] {
const card = l.cards[cardId];
if (!card) throw new Error(`Unknown card: ${cardId}`);
const program = getProgram(l, card.programVersion);
const earned: Reward[] = [];
program.tracks.forEach((track, i) => {
if (track.stampType !== type) return;
card.stamps[i] += 1;
if (card.stamps[i] < track.slots) return;
card.stamps[i] = 0;
earned.push(track.reward);
if (track.reward.kind === 'voucher') card.voucherCents += track.reward.valueCents;
else card.pendingRewards.push(track.reward);
});
return earned;
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/loyalty.test.ts
Expected: 9 tests PASS.
- Step 5: Commit
git add src/sim/loyalty.ts tests/sim/loyalty.test.ts
git commit -m "feat(sim): configurable loyalty program"
Task 10: État du jeu
Files:
-
Create:
src/sim/state.ts -
Test:
tests/sim/state.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import { createNewGame, DAY_START_MINUTES, setPrice, SHELF_SLOT_COUNT } from '../../src/sim/state';
import { START_DATE } from '../../src/sim/calendar';
import { STARTING_CASH_CENTS } from '../../src/sim/economy';
describe('state', () => {
it('creates a new game on 28/09/1990 at 8:00', () => {
const s = createNewGame(123);
expect(s.saveVersion).toBe(1);
expect(s.date).toEqual(START_DATE);
expect(s.minutes).toBe(DAY_START_MINUTES);
expect(s.cash).toBe(STARTING_CASH_CENTS);
expect(s.reputation).toBe(20);
expect(s.inventory.slots).toHaveLength(SHELF_SLOT_COUNT);
expect(s.rngState).toBe(123);
expect(s.bankrupt).toBe(false);
});
it('sets a positive price', () => {
const s = createNewGame(1);
setPrice(s, { gameId: 'tetris', condition: 'new' }, 2500);
expect(s.prices['tetris|new']).toBe(2500);
expect(() => setPrice(s, { gameId: 'tetris', condition: 'new' }, 0)).toThrow('Price must be positive');
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/state.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/state.ts
import { GameDate, START_DATE } from './calendar';
import { STARTING_CASH_CENTS } from './economy';
import { createInventory, Inventory, ItemRef, itemKey } from './inventory';
import { createLoyaltyState, LoyaltyState } from './loyalty';
export const DAY_START_MINUTES = 8 * 60;
export const OPEN_MINUTES = 9 * 60;
export const CLOSE_MINUTES = 19 * 60;
export const SHELF_SLOT_COUNT = 12;
export interface DayStats {
revenueCents: number;
expensesCents: number;
salesCount: number;
lostRequests: number;
buyBacks: number;
trades: number;
}
export interface PendingOrder {
item: ItemRef;
qty: number;
}
export interface GameState {
saveVersion: 1;
date: GameDate;
minutes: number;
cash: number;
reputation: number; // 0-100
inventory: Inventory;
prices: Record<string, number>;
loyalty: LoyaltyState;
pendingOrders: PendingOrder[];
day: DayStats;
negativeMonthEnds: number;
bankrupt: boolean;
rngState: number;
nextCustomerId: number;
customerAccumulator: number;
}
export function emptyDayStats(): DayStats {
return { revenueCents: 0, expensesCents: 0, salesCount: 0, lostRequests: 0, buyBacks: 0, trades: 0 };
}
export function createNewGame(seed: number): GameState {
return {
saveVersion: 1,
date: { ...START_DATE },
minutes: DAY_START_MINUTES,
cash: STARTING_CASH_CENTS,
reputation: 20,
inventory: createInventory(SHELF_SLOT_COUNT),
prices: {},
loyalty: createLoyaltyState(),
pendingOrders: [],
day: emptyDayStats(),
negativeMonthEnds: 0,
bankrupt: false,
rngState: seed,
nextCustomerId: 1,
customerAccumulator: 0,
};
}
export function setPrice(state: GameState, item: ItemRef, cents: number): void {
if (cents <= 0) throw new Error('Price must be positive');
state.prices[itemKey(item)] = cents;
}
export function adjustReputation(state: GameState, delta: number): void {
state.reputation = Math.min(100, Math.max(0, state.reputation + delta));
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/state.test.ts
Expected: 2 tests PASS.
- Step 5: Commit
git add src/sim/state.ts tests/sim/state.test.ts
git commit -m "feat(sim): game state"
Task 11: Commandes grossiste
Files:
-
Create:
src/sim/orders.ts -
Test:
tests/sim/orders.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import { placeOrder, receiveDeliveries } from '../../src/sim/orders';
import { createNewGame } from '../../src/sim/state';
import { backroomQty } from '../../src/sim/inventory';
import { testCatalog } from './helpers';
const catalog = testCatalog();
describe('orders', () => {
it('charges the wholesale cost and queues the delivery', () => {
const s = createNewGame(1);
const cashBefore = s.cash;
expect(placeOrder(s, catalog, 'sml', 3)).toBe(2467 * 3);
expect(s.cash).toBe(cashBefore - 2467 * 3);
expect(s.day.expensesCents).toBe(2467 * 3);
expect(s.pendingOrders).toEqual([{ item: { gameId: 'sml', condition: 'new' }, qty: 3 }]);
});
it('rejects invalid orders', () => {
const s = createNewGame(1);
expect(() => placeOrder(s, catalog, 'sml', 0)).toThrow('Quantity must be positive');
expect(() => placeOrder(s, catalog, 'future', 1)).toThrow('Not released yet: future');
s.cash = 100;
expect(() => placeOrder(s, catalog, 'sml', 1)).toThrow('Not enough cash');
});
it('moves deliveries into the backroom', () => {
const s = createNewGame(1);
placeOrder(s, catalog, 'sml', 2);
placeOrder(s, catalog, 'sml', 1);
const delivered = receiveDeliveries(s);
expect(delivered).toHaveLength(2);
expect(backroomQty(s.inventory, { gameId: 'sml', condition: 'new' })).toBe(3);
expect(s.pendingOrders).toEqual([]);
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/orders.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/orders.ts
import { compareDates, parseISO } from './calendar';
import { Catalog, getGame } from './catalog';
import { wholesaleCost } from './economy';
import { addToBackroom } from './inventory';
import { GameState, PendingOrder } from './state';
/** Pays the wholesaler now; the boxes arrive the next morning. Returns the cost. */
export function placeOrder(state: GameState, catalog: Catalog, gameId: string, qty: number): number {
if (qty <= 0) throw new Error('Quantity must be positive');
const game = getGame(catalog, gameId);
if (compareDates(parseISO(game.releaseDate), state.date) > 0) throw new Error(`Not released yet: ${gameId}`);
const cost = wholesaleCost(game) * qty;
if (cost > state.cash) throw new Error('Not enough cash');
state.cash -= cost;
state.day.expensesCents += cost;
state.pendingOrders.push({ item: { gameId, condition: 'new' }, qty });
return cost;
}
export function receiveDeliveries(state: GameState): PendingOrder[] {
const delivered = state.pendingOrders;
for (const order of delivered) addToBackroom(state.inventory, order.item, order.qty);
state.pendingOrders = [];
return delivered;
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/orders.test.ts
Expected: 3 tests PASS.
- Step 5: Commit
git add src/sim/orders.ts tests/sim/orders.test.ts
git commit -m "feat(sim): wholesaler orders and deliveries"
Task 12: Clients
Files:
-
Create:
src/sim/customers.ts -
Modify:
tests/sim/helpers.ts(ajout demakeCustomer,stock) -
Test:
tests/sim/customers.test.ts -
Step 1: Ajouter à
tests/sim/helpers.ts
import type { Customer } from '../../src/sim/customers';
import { addToBackroom, ItemRef, stockSlot } from '../../src/sim/inventory';
import type { GameState } from '../../src/sim/state';
export function makeCustomer(partial: Partial<Customer> = {}): Customer {
return { id: 1, intent: 'buy', cardId: null, wantedGameId: null, offered: null, ...partial };
}
/** Puts `qty` items straight onto a slot. */
export function stock(state: GameState, slotId: string, item: ItemRef, qty: number): void {
addToBackroom(state.inventory, item, qty);
stockSlot(state.inventory, slotId, item, qty);
}
(Fusionner les imports en tête du fichier avec l'import existant de catalog.)
- Step 2: Écrire le test
tests/sim/customers.test.ts
import { describe, it, expect } from 'vitest';
import { chooseShelfItem, customersPerHour, spawnCustomer } from '../../src/sim/customers';
import { createNewGame, setPrice } from '../../src/sim/state';
import { issueCard } from '../../src/sim/loyalty';
import { makeCustomer, stock, testCatalog } from './helpers';
const catalog = testCatalog();
const tetrisNew = { gameId: 'tetris', condition: 'new' as const };
const smlNew = { gameId: 'sml', condition: 'new' as const };
describe('customers', () => {
it('comes more often with a better reputation', () => {
expect(customersPerHour(0)).toBeCloseTo(0.5);
expect(customersPerHour(100)).toBeCloseTo(4.5);
});
it('spawns a mix of intents with valid data', () => {
const s = createNewGame(99);
const counts = { buy: 0, request: 0, buy_back: 0, trade: 0 };
for (let i = 0; i < 2000; i++) {
const c = spawnCustomer(s, catalog);
counts[c.intent]++;
if (c.intent === 'request') expect(['tetris', 'sml']).toContain(c.wantedGameId);
if (c.intent === 'buy_back' || c.intent === 'trade') {
expect(c.offered).not.toBeNull();
expect(c.offered!.condition).not.toBe('new');
expect(c.offered!.gameId).not.toBe('future');
}
}
expect(counts.buy / 2000).toBeGreaterThan(0.45);
expect(counts.buy / 2000).toBeLessThan(0.65);
expect(counts.request).toBeGreaterThan(0);
expect(counts.buy_back).toBeGreaterThan(0);
expect(counts.trade).toBeGreaterThan(0);
expect(s.nextCustomerId).toBe(2001);
});
it('brings back card holders', () => {
const s = createNewGame(5);
for (let i = 0; i < 30; i++) issueCard(s.loyalty);
let returning = 0;
for (let i = 0; i < 1000; i++) if (spawnCustomer(s, catalog).cardId !== null) returning++;
expect(returning).toBeGreaterThan(300);
expect(returning).toBeLessThan(700);
});
it('picks nothing from empty shelves', () => {
const s = createNewGame(1);
expect(chooseShelfItem(s, catalog, makeCustomer())).toBeNull();
});
it('never picks an item priced at 150% or more of its reference', () => {
const s = createNewGame(2);
stock(s, 'slot-0', tetrisNew, 5);
setPrice(s, tetrisNew, 3034 * 2);
for (let i = 0; i < 200; i++) expect(chooseShelfItem(s, catalog, makeCustomer())).toBeNull();
});
it('usually buys well-priced popular games', () => {
const s = createNewGame(3);
stock(s, 'slot-0', smlNew, 5);
stock(s, 'slot-1', tetrisNew, 5);
let picks = 0;
for (let i = 0; i < 200; i++) if (chooseShelfItem(s, catalog, makeCustomer()) !== null) picks++;
expect(picks).toBeGreaterThan(150);
});
it('finds the requested game, or nothing', () => {
const s = createNewGame(4);
stock(s, 'slot-3', smlNew, 1);
expect(chooseShelfItem(s, catalog, makeCustomer({ intent: 'request', wantedGameId: 'sml' }))).toBe('slot-3');
expect(chooseShelfItem(s, catalog, makeCustomer({ intent: 'request', wantedGameId: 'tetris' }))).toBeNull();
});
});
- Step 3: Vérifier l'échec
Run: npx vitest run tests/sim/customers.test.ts
Expected: FAIL.
- Step 4: Implémenter
src/sim/customers.ts
import { availableGames, Catalog, getGame } from './catalog';
import { demandWeight } from './demand';
import { priceOf, purchaseProbability, referencePrice } from './economy';
import { Condition, ItemRef, shelfItems, ShelfSlot } from './inventory';
import { currentProgram, returnVisitMultiplier } from './loyalty';
import { random, weightedPick } from './rng';
import { GameState } from './state';
export type Intent = 'buy' | 'request' | 'buy_back' | 'trade';
export interface Customer {
id: number;
intent: Intent;
cardId: string | null;
wantedGameId: string | null;
offered: ItemRef | null;
}
const INTENT_WEIGHTS: Array<[Intent, number]> = [
['buy', 0.55],
['request', 0.2],
['buy_back', 0.15],
['trade', 0.1],
];
const USED_CONDITIONS: Condition[] = ['complete', 'no_manual', 'loose'];
export function customersPerHour(reputation: number): number {
return 0.5 + reputation / 25;
}
function returningChance(state: GameState): number {
const cardCount = Object.keys(state.loyalty.cards).length;
return Math.min(0.5, cardCount * 0.01 * returnVisitMultiplier(currentProgram(state.loyalty)));
}
export function spawnCustomer(state: GameState, catalog: Catalog): Customer {
const id = state.nextCustomerId++;
const intent = weightedPick(state, INTENT_WEIGHTS, ([, w]) => w)![0];
let cardId: string | null = null;
if (random(state) < returningChance(state)) {
cardId = weightedPick(state, Object.keys(state.loyalty.cards), () => 1) ?? null;
}
const games = availableGames(catalog, state.date);
const pickGame = () => weightedPick(state, games, (g) => demandWeight(g, state.date) + 0.05);
let wantedGameId: string | null = null;
let offered: ItemRef | null = null;
if (intent === 'request') {
wantedGameId = pickGame()?.id ?? null;
}
if (intent === 'buy_back' || intent === 'trade') {
const game = pickGame();
const condition = weightedPick(state, USED_CONDITIONS, () => 1)!;
offered = game ? { gameId: game.id, condition } : null;
}
return { id, intent, cardId, wantedGameId, offered };
}
/** Returns the slot the customer takes an item from, or null if they leave empty-handed. */
export function chooseShelfItem(state: GameState, catalog: Catalog, customer: Customer): string | null {
const slots = shelfItems(state.inventory);
const priceRatio = (s: ShelfSlot) => {
const item = s.item!;
const game = getGame(catalog, item.gameId);
return purchaseProbability(priceOf(state.prices, catalog, item), referencePrice(game, item.condition));
};
if (customer.intent === 'request') {
const match = slots
.filter((s) => s.item!.gameId === customer.wantedGameId && priceRatio(s) > 0)
.sort((a, b) => Number(b.item!.condition === 'new') - Number(a.item!.condition === 'new'));
return match[0]?.id ?? null;
}
const weight = (s: ShelfSlot) => demandWeight(getGame(catalog, s.item!.gameId), state.date) * priceRatio(s);
const total = slots.reduce((sum, s) => sum + weight(s), 0);
if (random(state) >= Math.min(1, total)) return null;
return weightedPick(state, slots, weight)?.id ?? null;
}
- Step 5: Vérifier le succès
Run: npx vitest run tests/sim/customers.test.ts
Expected: 7 tests PASS.
- Step 6: Commit
git add src/sim/customers.ts tests/sim/customers.test.ts tests/sim/helpers.ts
git commit -m "feat(sim): customer generation and shelf choice"
Task 13: Transactions (vente, rachat, échange, demandes)
Files:
-
Create:
src/sim/transactions.ts -
Test:
tests/sim/transactions.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import { buyBack, recordLostRequest, recordSatisfiedRequest, sell, trade } from '../../src/sim/transactions';
import { createNewGame } from '../../src/sim/state';
import { backroomQty, getSlot } from '../../src/sim/inventory';
import { issueCard, setProgram } from '../../src/sim/loyalty';
import { makeCustomer, stock, testCatalog } from './helpers';
const catalog = testCatalog();
const tetrisNew = { gameId: 'tetris', condition: 'new' as const };
const tetrisLoose = { gameId: 'tetris', condition: 'loose' as const };
const smlComplete = { gameId: 'sml', condition: 'complete' as const };
describe('sell', () => {
it('takes the item, cashes the price and records the sale', () => {
const s = createNewGame(1);
stock(s, 'slot-0', tetrisNew, 2);
const cash = s.cash;
const result = sell(s, catalog, 'slot-0', makeCustomer());
expect(result.priceCents).toBe(3034);
expect(s.cash).toBe(cash + 3034);
expect(getSlot(s.inventory, 'slot-0').qty).toBe(1);
expect(s.day.salesCount).toBe(1);
expect(s.day.revenueCents).toBe(3034);
expect(s.reputation).toBeCloseTo(20.1);
});
it('stamps the card of a card holder', () => {
const s = createNewGame(1);
const card = issueCard(s.loyalty);
stock(s, 'slot-0', tetrisNew, 1);
sell(s, catalog, 'slot-0', makeCustomer({ cardId: card.id }));
expect(card.stamps).toEqual([1]);
});
it('applies a free used game reward', () => {
const s = createNewGame(1);
const card = issueCard(s.loyalty);
card.pendingRewards.push({ kind: 'free_used_game', maxValueCents: 2000 });
stock(s, 'slot-0', tetrisLoose, 1);
const cash = s.cash;
const result = sell(s, catalog, 'slot-0', makeCustomer({ cardId: card.id }));
expect(result.priceCents).toBe(0);
expect(result.rewardUsed).toEqual({ kind: 'free_used_game', maxValueCents: 2000 });
expect(s.cash).toBe(cash);
expect(card.pendingRewards).toEqual([]);
});
it('applies a percentage discount', () => {
const s = createNewGame(1);
const card = issueCard(s.loyalty);
card.pendingRewards.push({ kind: 'discount_percent', percent: 50 });
stock(s, 'slot-0', tetrisNew, 1);
expect(sell(s, catalog, 'slot-0', makeCustomer({ cardId: card.id })).priceCents).toBe(1517);
});
it('spends the card voucher', () => {
const s = createNewGame(1);
const card = issueCard(s.loyalty);
card.voucherCents = 1000;
stock(s, 'slot-0', tetrisNew, 1);
const cash = s.cash;
expect(sell(s, catalog, 'slot-0', makeCustomer({ cardId: card.id })).priceCents).toBe(2034);
expect(s.cash).toBe(cash + 2034);
expect(card.voucherCents).toBe(0);
});
});
describe('buyBack', () => {
it('is refused below what the customer accepts', () => {
const s = createNewGame(1);
const cash = s.cash;
expect(buyBack(s, catalog, makeCustomer({ intent: 'buy_back', offered: smlComplete }), 796)).toEqual({ accepted: false });
expect(s.cash).toBe(cash);
});
it('buys the game into the backroom when accepted', () => {
const s = createNewGame(1);
const cash = s.cash;
expect(buyBack(s, catalog, makeCustomer({ intent: 'buy_back', offered: smlComplete }), 797)).toEqual({ accepted: true });
expect(s.cash).toBe(cash - 797);
expect(backroomQty(s.inventory, smlComplete)).toBe(1);
expect(s.day.buyBacks).toBe(1);
expect(s.day.expensesCents).toBe(797);
});
it('throws when the shop cannot pay', () => {
const s = createNewGame(1);
s.cash = 100;
expect(() => buyBack(s, catalog, makeCustomer({ intent: 'buy_back', offered: smlComplete }), 797)).toThrow('Not enough cash');
});
it('stamps buy_back cards', () => {
const s = createNewGame(1);
setProgram(s.loyalty, [{ stampType: 'buy_back', slots: 5, reward: { kind: 'free_trade' } }]);
const card = issueCard(s.loyalty);
buyBack(s, catalog, makeCustomer({ intent: 'buy_back', offered: smlComplete, cardId: card.id }), 797);
expect(card.stamps).toEqual([1]);
});
});
describe('trade', () => {
it('is refused below the trade value the customer expects', () => {
const s = createNewGame(1);
stock(s, 'slot-0', tetrisNew, 1);
const result = trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete }), 'slot-0', 1138);
expect(result.accepted).toBe(false);
expect(getSlot(s.inventory, 'slot-0').qty).toBe(1);
});
it('makes the customer pay the difference', () => {
const s = createNewGame(1);
stock(s, 'slot-0', tetrisNew, 1);
const cash = s.cash;
const result = trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete }), 'slot-0', 1139);
expect(result).toEqual({ accepted: true, customerPaysCents: 1895, shopPaysCents: 0, voucherGivenCents: 0 });
expect(s.cash).toBe(cash + 1895);
expect(backroomQty(s.inventory, smlComplete)).toBe(1);
expect(getSlot(s.inventory, 'slot-0').qty).toBe(0);
expect(s.day.trades).toBe(1);
});
it('pays the excess in cash to customers without a card', () => {
const s = createNewGame(1);
stock(s, 'slot-0', tetrisNew, 1);
const cash = s.cash;
const result = trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete }), 'slot-0', 4000);
expect(result).toEqual({ accepted: true, customerPaysCents: 0, shopPaysCents: 966, voucherGivenCents: 0 });
expect(s.cash).toBe(cash - 966);
});
it('gives the excess as a voucher to card holders', () => {
const s = createNewGame(1);
const card = issueCard(s.loyalty);
stock(s, 'slot-0', tetrisNew, 1);
const cash = s.cash;
const result = trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete, cardId: card.id }), 'slot-0', 4000);
expect(result.voucherGivenCents).toBe(966);
expect(card.voucherCents).toBe(966);
expect(s.cash).toBe(cash);
});
it('waives the difference with a free trade reward', () => {
const s = createNewGame(1);
const card = issueCard(s.loyalty);
card.pendingRewards.push({ kind: 'free_trade' });
stock(s, 'slot-0', tetrisNew, 1);
const result = trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete, cardId: card.id }), 'slot-0', 1139);
expect(result.customerPaysCents).toBe(0);
expect(card.pendingRewards).toEqual([]);
});
});
describe('requests', () => {
it('costs reputation when the game is missing, clamped at 0', () => {
const s = createNewGame(1);
recordLostRequest(s);
expect(s.reputation).toBe(19);
expect(s.day.lostRequests).toBe(1);
s.reputation = 0.5;
recordLostRequest(s);
expect(s.reputation).toBe(0);
});
it('earns reputation when the game is found', () => {
const s = createNewGame(1);
recordSatisfiedRequest(s);
expect(s.reputation).toBe(20.5);
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/transactions.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/transactions.ts
import { Catalog, getGame } from './catalog';
import { customerMinimumAsk, priceOf } from './economy';
import { Customer } from './customers';
import { addToBackroom, ItemRef, takeFromSlot } from './inventory';
import { addStamp, currentProgram, issueCard, joinProbability, Reward, StampType } from './loyalty';
import { random } from './rng';
import { adjustReputation, GameState } from './state';
export interface SaleResult {
item: ItemRef;
priceCents: number;
rewardUsed: Reward | null;
rewardsEarned: Reward[];
}
export interface BuyBackResult {
accepted: boolean;
}
export interface TradeResult {
accepted: boolean;
customerPaysCents: number;
shopPaysCents: number;
voucherGivenCents: number;
}
function stampCard(state: GameState, customer: Customer, type: StampType): Reward[] {
if (!customer.cardId) return [];
return addStamp(state.loyalty, customer.cardId, type);
}
function maybeJoin(state: GameState, customer: Customer): void {
if (customer.cardId) return;
if (random(state) < joinProbability(currentProgram(state.loyalty))) {
customer.cardId = issueCard(state.loyalty).id;
}
}
function takeReward(state: GameState, customer: Customer, applies: (r: Reward) => boolean): Reward | null {
if (!customer.cardId) return null;
const card = state.loyalty.cards[customer.cardId];
const index = card.pendingRewards.findIndex(applies);
if (index === -1) return null;
return card.pendingRewards.splice(index, 1)[0];
}
function offeredItem(customer: Customer): ItemRef {
if (!customer.offered) throw new Error(`Customer ${customer.id} has nothing to sell`);
return customer.offered;
}
export function sell(state: GameState, catalog: Catalog, slotId: string, customer: Customer): SaleResult {
const item = takeFromSlot(state.inventory, slotId);
let price = priceOf(state.prices, catalog, item);
const rewardUsed = takeReward(state, customer, (r) =>
(r.kind === 'free_used_game' && item.condition !== 'new' && price <= r.maxValueCents) ||
r.kind === 'discount_percent',
);
if (rewardUsed?.kind === 'free_used_game') price = 0;
if (rewardUsed?.kind === 'discount_percent') price = Math.round((price * (100 - rewardUsed.percent)) / 100);
if (customer.cardId) {
const card = state.loyalty.cards[customer.cardId];
const fromVoucher = Math.min(card.voucherCents, price);
card.voucherCents -= fromVoucher;
price -= fromVoucher;
}
state.cash += price;
state.day.revenueCents += price;
state.day.salesCount += 1;
adjustReputation(state, 0.1);
maybeJoin(state, customer);
const rewardsEarned = stampCard(state, customer, item.condition === 'new' ? 'buy_new' : 'buy_used');
return { item, priceCents: price, rewardUsed, rewardsEarned };
}
export function buyBack(state: GameState, catalog: Catalog, customer: Customer, offerCents: number): BuyBackResult {
const item = offeredItem(customer);
const minimum = customerMinimumAsk(getGame(catalog, item.gameId), item.condition, 'cash');
if (offerCents < minimum) return { accepted: false };
if (offerCents > state.cash) throw new Error('Not enough cash');
state.cash -= offerCents;
state.day.expensesCents += offerCents;
state.day.buyBacks += 1;
addToBackroom(state.inventory, item, 1);
stampCard(state, customer, 'buy_back');
return { accepted: true };
}
export function trade(
state: GameState, catalog: Catalog, customer: Customer, slotId: string, creditCents: number,
): TradeResult {
const offered = offeredItem(customer);
const minimum = customerMinimumAsk(getGame(catalog, offered.gameId), offered.condition, 'trade');
if (creditCents < minimum) return { accepted: false, customerPaysCents: 0, shopPaysCents: 0, voucherGivenCents: 0 };
const wanted = takeFromSlot(state.inventory, slotId);
const difference = priceOf(state.prices, catalog, wanted) - creditCents;
let customerPaysCents = 0;
let shopPaysCents = 0;
let voucherGivenCents = 0;
if (difference >= 0) {
const freeTrade = takeReward(state, customer, (r) => r.kind === 'free_trade');
customerPaysCents = freeTrade ? 0 : difference;
state.cash += customerPaysCents;
state.day.revenueCents += customerPaysCents;
} else if (customer.cardId) {
voucherGivenCents = -difference;
state.loyalty.cards[customer.cardId].voucherCents += voucherGivenCents;
} else {
shopPaysCents = -difference;
state.cash -= shopPaysCents;
state.day.expensesCents += shopPaysCents;
}
addToBackroom(state.inventory, offered, 1);
state.day.trades += 1;
stampCard(state, customer, 'trade');
return { accepted: true, customerPaysCents, shopPaysCents, voucherGivenCents };
}
export function recordLostRequest(state: GameState): void {
state.day.lostRequests += 1;
adjustReputation(state, -1);
}
export function recordSatisfiedRequest(state: GameState): void {
adjustReputation(state, 0.5);
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/transactions.test.ts
Expected: 16 tests PASS.
- Step 5: Commit
git add src/sim/transactions.ts tests/sim/transactions.test.ts
git commit -m "feat(sim): sales, buy-backs, trades and requests"
Task 14: Horloge (tick)
Files:
-
Create:
src/sim/clock.ts -
Test:
tests/sim/clock.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import { isOpen, tick } from '../../src/sim/clock';
import { CLOSE_MINUTES, createNewGame, OPEN_MINUTES } from '../../src/sim/state';
import { testCatalog } from './helpers';
const catalog = testCatalog();
describe('clock', () => {
it('opens the shop at 9:00 with no customers before', () => {
const s = createNewGame(1);
const events = tick(s, catalog, 60);
expect(s.minutes).toBe(OPEN_MINUTES);
expect(events).toEqual([{ type: 'shop_opened' }]);
expect(isOpen(s)).toBe(true);
});
it('brings customers during opening hours and closes at 19:00', () => {
const s = createNewGame(1);
s.reputation = 100;
const events = tick(s, catalog, 24 * 60);
expect(s.minutes).toBe(CLOSE_MINUTES);
expect(events.filter((e) => e.type === 'customer_arrived').length).toBeGreaterThan(10);
expect(events[events.length - 1]).toEqual({ type: 'shop_closed' });
expect(isOpen(s)).toBe(false);
});
it('does nothing once closed', () => {
const s = createNewGame(1);
tick(s, catalog, 24 * 60);
expect(tick(s, catalog, 60)).toEqual([]);
expect(s.minutes).toBe(CLOSE_MINUTES);
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/clock.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/clock.ts
import { Catalog } from './catalog';
import { Customer, customersPerHour, spawnCustomer } from './customers';
import { random } from './rng';
import { CLOSE_MINUTES, GameState, OPEN_MINUTES } from './state';
export type SimEvent =
| { type: 'shop_opened' }
| { type: 'customer_arrived'; customer: Customer }
| { type: 'shop_closed' };
export function isOpen(state: GameState): boolean {
return state.minutes >= OPEN_MINUTES && state.minutes < CLOSE_MINUTES;
}
/** Advances the clock minute by minute, stopping at closing time. */
export function tick(state: GameState, catalog: Catalog, deltaMinutes: number): SimEvent[] {
const events: SimEvent[] = [];
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) {
state.customerAccumulator += (customersPerHour(state.reputation) / 60) * (0.5 + random(state));
while (state.customerAccumulator >= 1) {
state.customerAccumulator -= 1;
events.push({ type: 'customer_arrived', customer: spawnCustomer(state, catalog) });
}
}
if (state.minutes === CLOSE_MINUTES) events.push({ type: 'shop_closed' });
}
return events;
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/clock.test.ts
Expected: 3 tests PASS.
- Step 5: Commit
git add src/sim/clock.ts tests/sim/clock.test.ts
git commit -m "feat(sim): clock tick and events"
Task 15: Fin de journée (dimanche, loyer, faillite)
Files:
-
Create:
src/sim/day.ts -
Test:
tests/sim/day.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import { endDay } from '../../src/sim/day';
import { CLOSE_MINUTES, createNewGame, DAY_START_MINUTES } from '../../src/sim/state';
import { MONTHLY_RENT_CENTS } from '../../src/sim/economy';
import { placeOrder } from '../../src/sim/orders';
import { backroomQty } from '../../src/sim/inventory';
import { testCatalog } from './helpers';
const catalog = testCatalog();
function closed(seed = 1) {
const s = createNewGame(seed);
s.minutes = CLOSE_MINUTES;
return s;
}
describe('endDay', () => {
it('refuses to end the day before closing', () => {
expect(() => endDay(createNewGame(1))).toThrow('Shop is still open');
});
it('moves to the next morning and resets daily stats', () => {
const s = closed();
s.day.salesCount = 4;
const report = endDay(s);
expect(report.stats.salesCount).toBe(4);
expect(report.date).toEqual({ year: 1990, month: 9, day: 28 });
expect(s.date).toEqual({ year: 1990, month: 9, day: 29 });
expect(s.minutes).toBe(DAY_START_MINUTES);
expect(s.day.salesCount).toBe(0);
expect(report.rentPaidCents).toBe(0);
expect(report.skippedSunday).toBe(false);
});
it('skips Sunday and pays the rent when Sunday ends the month', () => {
const s = closed();
s.date = { year: 1990, month: 9, day: 29 };
const cash = s.cash;
const report = endDay(s);
expect(report.skippedSunday).toBe(true);
expect(report.rentPaidCents).toBe(MONTHLY_RENT_CENTS);
expect(report.newMonth).toBe(true);
expect(s.cash).toBe(cash - MONTHLY_RENT_CENTS);
expect(s.date).toEqual({ year: 1990, month: 10, day: 1 });
});
it('receives the deliveries for the next morning', () => {
const s = closed();
placeOrder(s, catalog, 'sml', 2);
const report = endDay(s);
expect(report.deliveries).toHaveLength(1);
expect(backroomQty(s.inventory, { gameId: 'sml', condition: 'new' })).toBe(2);
});
it('goes bankrupt after two negative month ends in a row', () => {
const s = closed();
s.cash = 0;
s.date = { year: 1990, month: 10, day: 31 };
expect(endDay(s).bankrupt).toBe(false);
expect(s.negativeMonthEnds).toBe(1);
s.date = { year: 1990, month: 11, day: 30 };
s.minutes = CLOSE_MINUTES;
expect(endDay(s).bankrupt).toBe(true);
expect(s.bankrupt).toBe(true);
});
it('resets the negative streak when a month ends positive', () => {
const s = closed();
s.negativeMonthEnds = 1;
s.date = { year: 1990, month: 10, day: 31 };
endDay(s);
expect(s.negativeMonthEnds).toBe(0);
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/day.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/day.ts
import { addDays, GameDate, isLastDayOfMonth, isSunday } from './calendar';
import { MONTHLY_RENT_CENTS } from './economy';
import { receiveDeliveries } from './orders';
import { CLOSE_MINUTES, DAY_START_MINUTES, DayStats, emptyDayStats, GameState, PendingOrder } from './state';
export interface DayReport {
date: GameDate;
stats: DayStats;
rentPaidCents: number;
skippedSunday: boolean;
newMonth: boolean;
bankrupt: boolean;
deliveries: PendingOrder[];
}
function closeMonth(state: GameState): number {
state.cash -= MONTHLY_RENT_CENTS;
state.negativeMonthEnds = state.cash < 0 ? state.negativeMonthEnds + 1 : 0;
if (state.negativeMonthEnds >= 2) state.bankrupt = true;
return MONTHLY_RENT_CENTS;
}
export function endDay(state: GameState): DayReport {
if (state.minutes < CLOSE_MINUTES) throw new Error('Shop is still open');
const endedDate = state.date;
const stats = state.day;
let rentPaidCents = 0;
if (isLastDayOfMonth(state.date)) rentPaidCents += closeMonth(state);
state.date = addDays(state.date, 1);
let skippedSunday = false;
if (isSunday(state.date)) {
skippedSunday = true;
if (isLastDayOfMonth(state.date)) rentPaidCents += closeMonth(state);
state.date = addDays(state.date, 1);
}
state.minutes = DAY_START_MINUTES;
state.day = emptyDayStats();
state.customerAccumulator = 0;
const deliveries = receiveDeliveries(state);
return {
date: endedDate,
stats,
rentPaidCents,
skippedSunday,
newMonth: state.date.month !== endedDate.month,
bankrupt: state.bankrupt,
deliveries,
};
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/day.test.ts
Expected: 6 tests PASS.
- Step 5: Commit
git add src/sim/day.ts tests/sim/day.test.ts
git commit -m "feat(sim): end of day, sundays, rent and bankruptcy"
Task 16: Sauvegarde
Files:
-
Create:
src/sim/save.ts -
Test:
tests/sim/save.test.ts -
Step 1: Écrire le test
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';
function memoryStorage(): SaveStorage {
const data = new Map<string, string>();
return { getItem: (k) => data.get(k) ?? null, setItem: (k, v) => void data.set(k, v) };
}
describe('save', () => {
it('round-trips the full state', () => {
const s = createNewGame(7);
issueCard(s.loyalty);
s.prices['tetris|new'] = 2500;
expect(deserialize(serialize(s))).toEqual(s);
});
it('rejects unknown save versions', () => {
const s = { ...createNewGame(1), saveVersion: 99 };
expect(() => deserialize(JSON.stringify(s))).toThrow('Unsupported save version: 99');
});
it('rejects malformed saves', () => {
expect(() => deserialize('{"saveVersion":1}')).toThrow('Corrupted save');
});
it('keeps the current and monthly saves apart', () => {
const storage = memoryStorage();
const s = createNewGame(1);
saveGame(storage, s, { monthly: true });
s.cash = 1;
saveGame(storage, s, { monthly: false });
expect(loadGame(storage, 'current')!.cash).toBe(1);
expect(loadGame(storage, 'monthly')!.cash).not.toBe(1);
});
it('returns null when nothing is saved', () => {
expect(loadGame(memoryStorage(), 'current')).toBeNull();
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/save.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/save.ts
import { GameState } from './state';
export interface SaveStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export type SaveSlot = 'current' | 'monthly';
export const SAVE_KEYS: Record<SaveSlot, string> = {
current: 'consolgames.save',
monthly: 'consolgames.save.monthly',
};
export function serialize(state: GameState): string {
return JSON.stringify(state);
}
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) {
throw new Error('Corrupted save');
}
return data as GameState;
}
/** Always writes the current save; also writes the monthly one when asked. */
export function saveGame(storage: SaveStorage, state: GameState, options: { monthly: boolean }): void {
const json = serialize(state);
storage.setItem(SAVE_KEYS.current, json);
if (options.monthly) storage.setItem(SAVE_KEYS.monthly, json);
}
export function loadGame(storage: SaveStorage, slot: SaveSlot): GameState | null {
const json = storage.getItem(SAVE_KEYS[slot]);
return json ? deserialize(json) : null;
}
- Step 4: Vérifier le succès
Run: npx vitest run tests/sim/save.test.ts
Expected: 5 tests PASS.
- Step 5: Commit
git add src/sim/save.ts tests/sim/save.test.ts
git commit -m "feat(sim): save and load"
Task 17: Joueur automatique + test de stabilité sur 1 an
Files:
-
Create:
src/sim/autoplay.ts -
Test:
tests/sim/autoplay.test.ts -
Step 1: Écrire le test
import { describe, it, expect } from 'vitest';
import { autoplayDay } from '../../src/sim/autoplay';
import { createNewGame } from '../../src/sim/state';
import { compareDates } from '../../src/sim/calendar';
import { loadDefaultCatalog } from '../../src/sim/catalog';
import { STARTING_CASH_CENTS } from '../../src/sim/economy';
describe('autoplay', () => {
it('plays one day end to end', () => {
const s = createNewGame(1);
const report = autoplayDay(s, loadDefaultCatalog());
expect(report.date).toEqual({ year: 1990, month: 9, day: 28 });
expect(s.date).toEqual({ year: 1990, month: 9, day: 29 });
});
it('keeps the economy stable over one year', () => {
const catalog = loadDefaultCatalog();
for (const seed of [1, 2, 3]) {
const s = createNewGame(seed);
let sales = 0;
let lost = 0;
while (compareDates(s.date, { year: 1991, month: 9, day: 28 }) < 0) {
const report = autoplayDay(s, catalog);
sales += report.stats.salesCount;
lost += report.stats.lostRequests;
expect(s.bankrupt).toBe(false);
}
expect(s.cash).toBeGreaterThan(0);
expect(s.cash).toBeLessThan(STARTING_CASH_CENTS * 100);
expect(sales).toBeGreaterThan(300);
expect(lost).toBeGreaterThan(0);
expect(s.reputation).toBeGreaterThan(0);
}
});
});
- Step 2: Vérifier l'échec
Run: npx vitest run tests/sim/autoplay.test.ts
Expected: FAIL.
- Step 3: Implémenter
src/sim/autoplay.ts
import { availableGames, Catalog, getGame } from './catalog';
import { tick } from './clock';
import { Customer, chooseShelfItem } from './customers';
import { DayReport, endDay } from './day';
import { demandWeight } from './demand';
import { customerMinimumAsk, MONTHLY_RENT_CENTS, wholesaleCost } from './economy';
import { itemKey, parseItemKey, stockSlot, totalQty } from './inventory';
import { placeOrder } from './orders';
import { CLOSE_MINUTES, GameState } from './state';
import { buyBack, recordLostRequest, recordSatisfiedRequest, sell, trade } from './transactions';
const ORDER_TOP_GAMES = 8;
const TARGET_STOCK = 3;
/** Moves every backroom item onto a matching or empty slot. */
export function restock(state: GameState): void {
for (const [key, qty] of Object.entries(state.inventory.backroom)) {
const item = parseItemKey(key);
let left = qty;
for (const slot of state.inventory.slots) {
if (left === 0) break;
if (slot.item && itemKey(slot.item) !== key) continue;
left -= stockSlot(state.inventory, slot.id, item, left);
}
}
}
function serve(state: GameState, catalog: Catalog, customer: Customer): void {
switch (customer.intent) {
case 'buy': {
const slotId = chooseShelfItem(state, catalog, customer);
if (slotId) sell(state, catalog, slotId, customer);
return;
}
case 'request': {
const slotId = chooseShelfItem(state, catalog, customer);
if (slotId) {
sell(state, catalog, slotId, customer);
recordSatisfiedRequest(state);
} else {
recordLostRequest(state);
}
return;
}
case 'buy_back': {
if (!customer.offered) return;
const game = getGame(catalog, customer.offered.gameId);
const ask = customerMinimumAsk(game, customer.offered.condition, 'cash');
if (state.cash - ask > MONTHLY_RENT_CENTS) buyBack(state, catalog, customer, ask);
return;
}
case 'trade': {
if (!customer.offered) return;
const slotId = chooseShelfItem(state, catalog, { ...customer, intent: 'buy' });
if (!slotId) return;
const game = getGame(catalog, customer.offered.gameId);
trade(state, catalog, customer, slotId, customerMinimumAsk(game, customer.offered.condition, 'trade'));
return;
}
}
}
function autoOrder(state: GameState, catalog: Catalog): void {
const top = availableGames(catalog, state.date)
.sort((a, b) => demandWeight(b, state.date) - demandWeight(a, state.date))
.slice(0, ORDER_TOP_GAMES);
for (const game of top) {
const missing = TARGET_STOCK - totalQty(state.inventory, { gameId: game.id, condition: 'new' });
if (missing <= 0) continue;
if (state.cash - wholesaleCost(game) * missing < MONTHLY_RENT_CENTS) continue;
placeOrder(state, catalog, game.id, missing);
}
}
/** Plays a full day with a simple scripted strategy. */
export function autoplayDay(state: GameState, catalog: Catalog): DayReport {
restock(state);
while (state.minutes < CLOSE_MINUTES) {
for (const event of tick(state, catalog, 60)) {
if (event.type === 'customer_arrived') serve(state, catalog, event.customer);
}
}
autoOrder(state, catalog);
return endDay(state);
}
- Step 4: Lancer le test
Run: npx vitest run tests/sim/autoplay.test.ts
Expected: 2 tests PASS.
Si le test de stabilité échoue, ne pas modifier les assertions. Mesurer d'abord (log temporaire de cash, sales, reputation en fin d'année par seed), puis ajuster une seule constante d'équilibrage à la fois parmi MONTHLY_RENT_CENTS (economy.ts), customersPerHour (customers.ts) et STARTING_CASH_CENTS (economy.ts). Mettre à jour les tests unitaires impactés par la nouvelle valeur, et noter le changement et sa raison dans le message de commit.
- Step 5: Commit
git add src/sim/autoplay.ts tests/sim/autoplay.test.ts
git commit -m "feat(sim): scripted autoplay and one-year stability test"
Task 18: Exports publics + vérification finale
Files:
-
Create:
src/sim/index.ts -
Delete:
tests/sim/smoke.test.ts -
Step 1: Créer
src/sim/index.ts
export * from './autoplay';
export * from './calendar';
export * from './catalog';
export * from './clock';
export * from './customers';
export * from './day';
export * from './demand';
export * from './economy';
export * from './inventory';
export * from './loyalty';
export * from './money';
export * from './orders';
export * from './rng';
export * from './save';
export * from './state';
export * from './transactions';
- Step 2: Supprimer le smoke test
Run: git rm tests/sim/smoke.test.ts
- Step 3: Typecheck
Run: npx tsc --noEmit
Expected: aucune erreur.
- Step 4: Suite complète
Run: npx vitest run
Expected: tous les tests PASS (16 fichiers).
- Step 5: Commit
git add src/sim/index.ts
git commit -m "feat(sim): public exports"
Suite
Le plan 2 (rendu Phaser : boutique en vue de dessus, personnage, mise en rayon, caisse, menus pixel GBA, configuration de la carte de fidélité, bilan de journée, sauvegarde localStorage et export/import JSON) sera écrit une fois ce plan exécuté, à partir de l'API réelle de src/sim/index.ts.