diff --git a/docs/superpowers/plans/2026-10-08-sim-core.md b/docs/superpowers/plans/2026-10-08-sim-core.md new file mode 100644 index 0000000..b8eeff5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-sim-core.md @@ -0,0 +1,2643 @@ +# 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}` + `minutes` depuis 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/.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`** + +```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`** + +```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`** + +```ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, +}); +``` + +- [ ] **Step 5: Créer `tests/sim/smoke.test.ts`** + +```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** + +```bash +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** + +```ts +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(); + 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`** + +```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( + 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** + +```bash +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** + +```ts +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`** + +```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** + +```bash +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** + +```ts +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`** + +```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** + +```bash +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`** + +```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) + +```ts +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`** + +```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`** + +```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 { + launchPriceFrancs: number; +} + +export interface Catalog { + games: Game[]; + byId: Map; +} + +export function createCatalog(games: Game[]): Catalog { + const byId = new Map(); + 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** + +```bash +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** + +```ts +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`** + +```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; + 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** + +```bash +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** + +```ts +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`** + +```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, 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, 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** + +```bash +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** + +```ts +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`** + +```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** + +```bash +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** + +```ts +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`** + +```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; + 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** + +```bash +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** + +```ts +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`** + +```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; + 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** + +```bash +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** + +```ts +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`** + +```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** + +```bash +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 de `makeCustomer`, `stock`) +- Test: `tests/sim/customers.test.ts` + +- [ ] **Step 1: Ajouter à `tests/sim/helpers.ts`** + +```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 { + 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`** + +```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`** + +```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** + +```bash +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** + +```ts +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`** + +```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** + +```bash +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** + +```ts +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`** + +```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** + +```bash +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** + +```ts +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`** + +```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** + +```bash +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** + +```ts +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(); + 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`** + +```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 = { + 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** + +```bash +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** + +```ts +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`** + +```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** + +```bash +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`** + +```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** + +```bash +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`. diff --git a/docs/superpowers/specs/2026-10-08-game-shop-sim-design.md b/docs/superpowers/specs/2026-10-08-game-shop-sim-design.md index 06d2e3f..012d253 100644 --- a/docs/superpowers/specs/2026-10-08-game-shop-sim-design.md +++ b/docs/superpowers/specs/2026-10-08-game-shop-sim-design.md @@ -5,7 +5,7 @@ Usage : personnel uniquement (vrais jeux, vraies jaquettes). ## Vision -Simulateur de magasin de jeux vidéo dans l'esprit de *TCG Card Shop Simulator*, en France, de 1989 (Game Boy) à 2004 (sortie de la Nintendo DS), pour revivre l'époque GB/GBC/GBA. Rendu 2D pixel art vue de dessus, style GBA (Pokémon Rubis/Émeraude). Le joueur incarne le gérant et fait tout à la main avant de pouvoir déléguer. +Simulateur de magasin de jeux vidéo dans l'esprit de *TCG Card Shop Simulator*, en France, du 28/09/1990 (sortie européenne de la Game Boy) à 2004 (sortie de la Nintendo DS), pour revivre l'époque GB/GBC/GBA. Rendu 2D pixel art vue de dessus, style GBA (Pokémon Rubis/Émeraude). Le joueur incarne le gérant et fait tout à la main avant de pouvoir déléguer. ## Décisions clés @@ -15,7 +15,7 @@ Simulateur de magasin de jeux vidéo dans l'esprit de *TCG Card Shop Simulator*, | Pays | France : dates de sortie FR, titres FR, francs puis euros au 01/01/2002 | | Temps | 1 jour de jeu = 1 jour calendaire. 1 min réelle = 1 h en jeu. Ouverture 9h–19h (10 min réelles). Vitesse ×1/×2/×4/pause | | Calendrier | Chronologie réelle : les jeux et consoles sortent à leur date FR | -| Fin | Campagne 1989 → 2004 avec bilan final, puis mode libre | +| Fin | Campagne 28/09/1990 → 2004 avec bilan final, puis mode libre | | Produits | Jeux neufs + occasion (rachat/échange) dès le départ ; consoles et accessoires débloqués plus tard | | Interaction | Tout à la main (déballer, ranger, encaisser, négocier), employés débloquables ensuite | | Plateforme | Navigateur sur Mac, clavier + souris | @@ -93,7 +93,7 @@ src/ ## Premier jalon (MVP) -1. Période 1989–1990, Game Boy uniquement, ~20 jeux. +1. Période 28/09/1990 – fin 1991, Game Boy uniquement, ~20 jeux. 2. Boutique d'une pièce : déplacement, mise en rayon, caisse. 3. Clients : achat, rachat, échange, demande précise. 4. Carte de fidélité configurable (types de tampons, nombre de cases, récompenses), sans habitués nommés.