Compare commits

...
30 Commits
Author SHA1 Message Date
Mahtan b62fa8006c Merge pull request 'Moteur de simulation (plan 1/2)' (#1) from feat/sim-core into main 2026-10-08 14:05:53 +02:00
Victor Bodinaud 70599d59b1 fix(sim): autoplay avoids unaffordable trades 2026-10-08 12:50:06 +02:00
Victor Bodinaud a7d3be7e1b fix(sim): validate bankruptcy fields on load 2026-10-08 12:49:46 +02:00
Victor Bodinaud d78e6ebd26 feat(sim): move items from shelf back to backroom 2026-10-08 12:49:23 +02:00
Victor Bodinaud b1d0c4fd16 fix(sim): display whole franc amounts without conversion drift 2026-10-08 12:49:07 +02:00
Victor Bodinaud 736470eb78 feat(sim): changeLoyaltyProgram applies reputation penalty 2026-10-08 12:48:44 +02:00
Victor Bodinaud d978834d7e fix(sim): accumulate fractional ticks 2026-10-08 12:48:13 +02:00
Victor Bodinaud 495da04b3e feat(sim): public exports 2026-10-08 12:43:58 +02:00
Victor Bodinaud 0de26d33f7 feat(sim): scripted autoplay and one-year stability test 2026-10-08 12:43:47 +02:00
Victor Bodinaud a677b9c61c fix(sim): no arrivals at closing time, robust save import 2026-10-08 12:43:14 +02:00
Victor Bodinaud 73e5ee30b6 feat(sim): save and load 2026-10-08 12:40:46 +02:00
Victor Bodinaud d66832e4a1 feat(sim): end of day, sundays, rent and bankruptcy 2026-10-08 12:40:27 +02:00
Victor Bodinaud af10c3e8fd feat(sim): clock tick and events 2026-10-08 12:40:10 +02:00
Victor Bodinaud 3a8a110a2e chore(sim): review nits 2026-10-08 12:39:34 +02:00
Victor Bodinaud 0fc2133e40 fix(sim): loyalty migration keeps stamps, reward edge cases 2026-10-08 12:38:06 +02:00
Victor Bodinaud 959f2ae537 feat(sim): sales, buy-backs, trades and requests 2026-10-08 12:35:16 +02:00
Victor Bodinaud d1fbe3b8e3 feat(sim): customer generation and shelf choice 2026-10-08 12:35:06 +02:00
Victor Bodinaud 0ce9cde174 fix(sim): migrate completed loyalty cards to the current program 2026-10-08 12:34:41 +02:00
Victor Bodinaud 9453a936b6 feat(sim): wholesaler orders and deliveries 2026-10-08 12:32:50 +02:00
Victor Bodinaud 923b6bf080 feat(sim): game state 2026-10-08 12:32:33 +02:00
Victor Bodinaud 7ed64476f8 feat(sim): configurable loyalty program 2026-10-08 12:32:18 +02:00
Victor Bodinaud 8c2fff96d6 feat(sim): release demand curve 2026-10-08 12:30:40 +02:00
Victor Bodinaud d97d60bf7e feat(sim): economy rules 2026-10-08 12:30:36 +02:00
Victor Bodinaud df24cf7516 feat(sim): inventory with backroom and shelf slots 2026-10-08 12:30:33 +02:00
Victor Bodinaud 9765e75dd2 feat(sim): game catalog with GB 1990-1991 data 2026-10-08 12:30:21 +02:00
Victor Bodinaud b2542c0020 chore: ignore .vitest 2026-10-08 12:28:29 +02:00
Victor Bodinaud cb103bf71a feat(sim): franc/euro money helpers 2026-10-08 12:28:09 +02:00
Victor Bodinaud b6054086c3 feat(sim): calendar 2026-10-08 12:27:52 +02:00
Victor Bodinaud 13192ee0bc feat(sim): seeded PRNG 2026-10-08 12:27:32 +02:00
Victor Bodinaud cb1c4177d1 chore: scaffold TypeScript + Vitest 2026-10-08 12:27:12 +02:00
40 changed files with 3736 additions and 0 deletions

No files matched your search

+1
View File
@@ -2,3 +2,4 @@
node_modules/
dist/
.DS_Store
.vitest/
+1524
View File
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "consolgames",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^7.0.2",
"vitest": "^5.0.3"
}
}
+24
View File
@@ -0,0 +1,24 @@
[
{ "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 }
]
+91
View File
@@ -0,0 +1,91 @@
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, priceOf, wholesaleCost } from './economy';
import { getSlot, 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);
}
}
}
export function serveCustomer(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);
const credit = customerMinimumAsk(game, customer.offered.condition, 'trade');
const slotItem = getSlot(state.inventory, slotId).item;
if (slotItem && !customer.cardId) {
const diff = priceOf(state.prices, catalog, slotItem) - credit;
if (diff < 0 && -diff > state.cash) return;
}
trade(state, catalog, customer, slotId, credit);
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') serveCustomer(state, catalog, event.customer);
}
}
autoOrder(state, catalog);
return endDay(state);
}
+54
View File
@@ -0,0 +1,54 @@
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 };
}
+58
View File
@@ -0,0 +1,58 @@
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}`);
if (!(g.launchPriceCents > 0)) throw new Error(`Invalid launch price: ${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);
}
+39
View File
@@ -0,0 +1,39 @@
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;
}
/** Tolerance so that e.g. 60 * (1/60) counts as a whole minute despite float error. */
const MINUTE_EPSILON = 1e-9;
/** Advances the clock by (possibly fractional) minutes, stepping whole minutes and stopping at closing time. */
export function tick(state: GameState, catalog: Catalog, deltaMinutes: number): SimEvent[] {
const events: SimEvent[] = [];
state.minuteAccumulator += deltaMinutes;
while (state.minuteAccumulator >= 1 - MINUTE_EPSILON && state.minutes < CLOSE_MINUTES) {
state.minuteAccumulator = Math.max(0, state.minuteAccumulator - 1);
state.minutes += 1;
if (state.minutes === OPEN_MINUTES) events.push({ type: 'shop_opened' });
if (state.minutes > OPEN_MINUTES && state.minutes < CLOSE_MINUTES) {
state.customerAccumulator += (customersPerHour(state.reputation) / 60) * (0.5 + random(state));
while (state.customerAccumulator >= 1) {
state.customerAccumulator -= 1;
events.push({ type: 'customer_arrived', customer: spawnCustomer(state, catalog) });
}
}
if (state.minutes === CLOSE_MINUTES) {
state.minuteAccumulator = 0;
events.push({ type: 'shop_closed' });
}
}
return events;
}
+81
View File
@@ -0,0 +1,81 @@
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;
}
+54
View File
@@ -0,0 +1,54 @@
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;
state.minuteAccumulator = 0;
const deliveries = receiveDeliveries(state);
return {
date: endedDate,
stats,
rentPaidCents,
skippedSunday,
newMonth: state.date.month !== endedDate.month,
bankrupt: state.bankrupt,
deliveries,
};
}
+15
View File
@@ -0,0 +1,15 @@
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);
}
+40
View File
@@ -0,0 +1,40 @@
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);
}
+16
View File
@@ -0,0 +1,16 @@
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';
+98
View File
@@ -0,0 +1,98 @@
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;
}
/** Moves up to `qty` items from the slot back to the backroom. Returns the quantity moved. */
export function unstockSlot(inv: Inventory, slotId: string, qty: number): number {
const slot = getSlot(inv, slotId);
if (!slot.item || slot.qty === 0) return 0;
const moved = Math.min(qty, slot.qty);
if (moved <= 0) return 0;
addToBackroom(inv, slot.item, moved);
slot.qty -= moved;
if (slot.qty === 0) slot.item = null;
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;
}
+144
View File
@@ -0,0 +1,144 @@
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: structuredClone(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) => !Number.isInteger(t.slots) || t.slots < 3 || t.slots > 20)) return 'Entre 3 et 20 cases par type';
if (tracks.some((t) => !isValidReward(t.reward))) return 'Récompense invalide';
return null;
}
function isValidReward(r: Reward): boolean {
switch (r.kind) {
case 'free_trade': return true;
case 'free_used_game': return r.maxValueCents > 0;
case 'voucher': return r.valueCents > 0;
case 'discount_percent': return r.percent >= 1 && r.percent <= 100;
}
}
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: structuredClone(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[] = [];
let completed = false;
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;
completed = true;
earned.push(track.reward);
if (track.reward.kind === 'voucher') card.voucherCents += track.reward.valueCents;
else card.pendingRewards.push(track.reward);
});
const current = currentProgram(l);
if (completed && card.programVersion !== current.version) {
card.stamps = current.tracks.map((track) => {
const oldIndex = program.tracks.findIndex((t) => t.stampType === track.stampType);
return oldIndex === -1 ? 0 : Math.min(card.stamps[oldIndex], track.slots - 1);
});
card.programVersion = current.version;
}
return earned;
}
+19
View File
@@ -0,0 +1,19 @@
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);
// Francs are rounded to 0.10 F so that euro-cent storage does not show drift (299,97 F for 300 F).
const value = euro ? cents / 100 : Math.round(centsToFrancs(cents) * 10) / 10;
const text = value.toFixed(2).replace('.', ',');
return euro ? `${text} €` : `${text} F`;
}
+25
View File
@@ -0,0 +1,25 @@
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;
}
+35
View File
@@ -0,0 +1,35 @@
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;
}
+52
View File
@@ -0,0 +1,52 @@
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);
}
const OBJECT_FIELDS = ['date', 'inventory', 'loyalty', 'day', 'prices', 'pendingOrders'] as const;
const NUMBER_FIELDS = ['cash', 'minutes', 'reputation', 'rngState', 'nextCustomerId', 'customerAccumulator', 'minuteAccumulator', 'negativeMonthEnds'] as const;
export function deserialize(json: string): GameState {
let data: unknown;
try {
data = JSON.parse(json);
} catch {
throw new Error('Corrupted save');
}
if (typeof data !== 'object' || data === null || Array.isArray(data)) throw new Error('Corrupted save');
const record = data as Record<string, unknown>;
if (record.saveVersion !== 1) throw new Error(`Unsupported save version: ${record.saveVersion}`);
for (const field of OBJECT_FIELDS) {
if (typeof record[field] !== 'object' || record[field] === null) throw new Error('Corrupted save');
}
for (const field of NUMBER_FIELDS) {
if (typeof record[field] !== 'number') throw new Error('Corrupted save');
}
if (typeof record.bankrupt !== 'boolean') 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;
}
+76
View File
@@ -0,0 +1,76 @@
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;
minuteAccumulator: 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,
minuteAccumulator: 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));
}
+144
View File
@@ -0,0 +1,144 @@
import { Catalog, getGame } from './catalog';
import { customerMinimumAsk, priceOf } from './economy';
import { Customer } from './customers';
import { addToBackroom, getSlot, ItemRef, takeFromSlot } from './inventory';
import { addStamp, currentProgram, issueCard, joinProbability, Reward, setProgram, StampTrack, 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) ??
takeReward(state, customer, (r) => 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 slotItem = getSlot(state.inventory, slotId).item;
if (slotItem && !customer.cardId) {
const excess = creditCents - priceOf(state.prices, catalog, slotItem);
if (excess > state.cash) throw new Error('Not enough cash');
}
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 = difference > 0 ? takeReward(state, customer, (r) => r.kind === 'free_trade') : null;
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 };
}
/** Replaces the loyalty program and applies the reputation penalty for a less generous one. */
export function changeLoyaltyProgram(state: GameState, tracks: StampTrack[]): void {
const { reputationDelta } = setProgram(state.loyalty, tracks);
adjustReputation(state, reputationDelta);
}
export function recordLostRequest(state: GameState): void {
state.day.lostRequests += 1;
adjustReputation(state, -1);
}
export function recordSatisfiedRequest(state: GameState): void {
adjustReputation(state, 0.5);
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { autoplayDay, serveCustomer } 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';
import { getSlot } from '../../src/sim/inventory';
import { makeCustomer, stock, testCatalog } from './helpers';
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);
}
});
it('skips trades the shop cannot afford instead of throwing', () => {
const catalog = testCatalog();
const s = createNewGame(1);
const tetrisNew = { gameId: 'tetris', condition: 'new' as const };
stock(s, 'slot-0', tetrisNew, 1);
s.prices['tetris|new'] = 100;
s.cash = 0;
const customer = makeCustomer({ intent: 'trade', offered: { gameId: 'sml', condition: 'complete' } });
expect(() => serveCustomer(s, catalog, customer)).not.toThrow();
expect(getSlot(s.inventory, 'slot-0').qty).toBe(1);
expect(s.day.trades).toBe(0);
});
});
+44
View File
@@ -0,0 +1,44 @@
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);
});
});
+41
View File
@@ -0,0 +1,41 @@
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');
});
it('rejects games with a non-positive launch price', () => {
expect(() => createCatalog([{ ...TEST_GAMES[0], id: 'free', launchPriceCents: 0 }])).toThrow('Invalid launch price: free');
});
});
+69
View File
@@ -0,0 +1,69 @@
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('never brings customers on the closing tick', () => {
for (let seed = 1; seed <= 20; seed++) {
const s = createNewGame(seed);
s.reputation = 100;
const arrivalMinutes: number[] = [];
let opened = 0;
let closed = 0;
while (s.minutes < CLOSE_MINUTES) {
const events = tick(s, catalog, 1);
if (events.some((e) => e.type === 'customer_arrived')) arrivalMinutes.push(s.minutes);
opened += events.filter((e) => e.type === 'shop_opened').length;
closed += events.filter((e) => e.type === 'shop_closed').length;
}
expect(arrivalMinutes.length).toBeGreaterThan(0);
expect(arrivalMinutes).not.toContain(CLOSE_MINUTES);
expect(opened).toBe(1);
expect(closed).toBe(1);
}
});
it('accumulates fractional ticks', () => {
const s = createNewGame(1);
const start = s.minutes;
for (let i = 0; i < 60; i++) tick(s, catalog, 1 / 60);
expect(s.minutes).toBe(start + 1);
});
it('advances one minute after two half-minute ticks', () => {
const s = createNewGame(1);
const start = s.minutes;
tick(s, catalog, 0.5);
expect(s.minutes).toBe(start);
tick(s, catalog, 0.5);
expect(s.minutes).toBe(start + 1);
});
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);
});
});
+74
View File
@@ -0,0 +1,74 @@
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();
});
});
+74
View File
@@ -0,0 +1,74 @@
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);
});
});
+33
View File
@@ -0,0 +1,33 @@
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);
});
});
+45
View File
@@ -0,0 +1,45 @@
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);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { createCatalog, Game } from '../../src/sim/catalog';
import type { Customer } from '../../src/sim/customers';
import { addToBackroom, ItemRef, stockSlot } from '../../src/sim/inventory';
import type { GameState } from '../../src/sim/state';
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);
}
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);
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import {
addToBackroom, backroomQty, createInventory, getSlot, itemKey, parseItemKey,
shelfItems, SLOT_CAPACITY, stockSlot, takeFromSlot, totalQty, unstockSlot,
} 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('moves items from a slot back to the backroom', () => {
const inv = createInventory(1);
addToBackroom(inv, tetrisNew, 4);
stockSlot(inv, 'slot-0', tetrisNew, 4);
expect(unstockSlot(inv, 'slot-0', 3)).toBe(3);
expect(getSlot(inv, 'slot-0').qty).toBe(1);
expect(backroomQty(inv, tetrisNew)).toBe(3);
});
it('caps unstocking to the slot quantity and clears the slot at 0', () => {
const inv = createInventory(1);
addToBackroom(inv, tetrisNew, 2);
stockSlot(inv, 'slot-0', tetrisNew, 2);
expect(unstockSlot(inv, 'slot-0', 10)).toBe(2);
expect(getSlot(inv, 'slot-0')).toEqual({ id: 'slot-0', item: null, qty: 0 });
expect(backroomQty(inv, tetrisNew)).toBe(2);
});
it('returns 0 when unstocking an empty slot', () => {
const inv = createInventory(1);
expect(unstockSlot(inv, 'slot-0', 3)).toBe(0);
});
it('throws on unknown slot', () => {
expect(() => getSlot(createInventory(1), 'slot-9')).toThrow('Unknown slot: slot-9');
});
});
+146
View File
@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest';
import {
addStamp, createLoyaltyState, currentProgram, DEFAULT_TRACKS, 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);
});
it('migrates a card to the current program once it completes a track', () => {
const l = createLoyaltyState();
const card = issueCard(l);
setProgram(l, [{ stampType: 'buy_new', slots: 3, reward: { kind: 'voucher', valueCents: 500 } }]);
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.programVersion).toBe(2);
expect(card.stamps).toEqual([0]);
expect(card.pendingRewards).toHaveLength(1);
addStamp(l, card.id, 'buy_new');
addStamp(l, card.id, 'buy_new');
expect(addStamp(l, card.id, 'buy_new')).toEqual([{ kind: 'voucher', valueCents: 500 }]);
expect(card.voucherCents).toBe(500);
});
it('stores deep copies of tracks', () => {
const l = createLoyaltyState();
const tracks: StampTrack[] = [{ stampType: 'buy_new', slots: 5, reward: { kind: 'free_trade' } }];
setProgram(l, tracks);
tracks[0].slots = 99;
expect(currentProgram(l).tracks[0].slots).toBe(5);
const first = l.programs[0].tracks[0];
expect(first).not.toBe(DEFAULT_TRACKS[0]);
});
it('rejects non-integer slots and non-positive rewards', () => {
const t = (slots: number, reward: StampTrack['reward']): StampTrack => ({ stampType: 'buy_new', slots, reward });
expect(validateTracks([t(3.5, { kind: 'free_trade' })])).toBe('Entre 3 et 20 cases par type');
expect(validateTracks([t(5, { kind: 'free_used_game', maxValueCents: 0 })])).toBe('Récompense invalide');
expect(validateTracks([t(5, { kind: 'voucher', valueCents: -1 })])).toBe('Récompense invalide');
expect(validateTracks([t(5, { kind: 'discount_percent', percent: 0 })])).toBe('Récompense invalide');
expect(validateTracks([t(5, { kind: 'discount_percent', percent: 101 })])).toBe('Récompense invalide');
expect(validateTracks([t(5, { kind: 'discount_percent', percent: 100 })])).toBeNull();
});
it('keeps partial progress on other tracks when migrating, capped to the new slots', () => {
const l = createLoyaltyState();
setProgram(l, [
{ stampType: 'buy_new', slots: 8, reward: { kind: 'free_trade' } },
{ stampType: 'trade', slots: 4, reward: { kind: 'free_trade' } },
]);
const card = issueCard(l);
card.stamps = [7, 3];
setProgram(l, [
{ stampType: 'buy_new', slots: 5, reward: { kind: 'free_trade' } },
{ stampType: 'trade', slots: 4, reward: { kind: 'free_trade' } },
]);
expect(addStamp(l, card.id, 'trade')).toEqual([{ kind: 'free_trade' }]);
expect(card.programVersion).toBe(3);
expect(card.stamps).toEqual([4, 0]);
});
});
+34
View File
@@ -0,0 +1,34 @@
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('displays whole franc amounts without conversion drift', () => {
const d = { year: 1990, month: 9, day: 28 };
expect(formatMoney(francsToCents(300), d)).toBe('300,00 F');
expect(formatMoney(francsToCents(149), d)).toBe('149,00 F');
expect(formatMoney(francsToCents(6000), d)).toBe('6000,00 F');
expect(formatMoney(francsToCents(249.9), d)).toBe('249,90 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 €');
});
});
+36
View File
@@ -0,0 +1,36 @@
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([]);
});
});
+48
View File
@@ -0,0 +1,48 @@
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();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { deserialize, loadGame, saveGame, SaveStorage, serialize } from '../../src/sim/save';
import { createNewGame } from '../../src/sim/state';
import { issueCard } from '../../src/sim/loyalty';
import { STARTING_CASH_CENTS } from '../../src/sim/economy';
function memoryStorage(): SaveStorage {
const data = new Map<string, string>();
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('rejects invalid JSON and non-object saves', () => {
expect(() => deserialize('{not json')).toThrow('Corrupted save');
expect(() => deserialize('null')).toThrow('Corrupted save');
expect(() => deserialize('[]')).toThrow('Corrupted save');
expect(() => deserialize('42')).toThrow('Corrupted save');
});
it('rejects saves with a missing field', () => {
const { day: _day, ...rest } = createNewGame(1);
expect(() => deserialize(JSON.stringify(rest))).toThrow('Corrupted save');
});
it('rejects saves missing bankruptcy fields', () => {
const { negativeMonthEnds: _n, ...noMonths } = createNewGame(1);
expect(() => deserialize(JSON.stringify(noMonths))).toThrow('Corrupted save');
const { bankrupt: _b, ...noBankrupt } = createNewGame(1);
expect(() => deserialize(JSON.stringify(noBankrupt))).toThrow('Corrupted save');
expect(() => deserialize(JSON.stringify({ ...createNewGame(1), bankrupt: 'no' }))).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).toBe(STARTING_CASH_CENTS);
});
it('returns null when nothing is saved', () => {
expect(loadGame(memoryStorage(), 'current')).toBeNull();
});
});
+25
View File
@@ -0,0 +1,25 @@
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');
});
});
+217
View File
@@ -0,0 +1,217 @@
import { describe, it, expect } from 'vitest';
import { buyBack, changeLoyaltyProgram, 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);
});
});
describe('reward and cash edge cases', () => {
it('keeps the free trade reward when there is no difference to waive', () => {
const s = createNewGame(1);
const card = issueCard(s.loyalty);
card.pendingRewards.push({ kind: 'free_trade' });
stock(s, 'slot-0', tetrisNew, 1);
trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete, cardId: card.id }), 'slot-0', 3034);
expect(card.pendingRewards).toEqual([{ kind: 'free_trade' }]);
});
it('prefers a free used game over a discount', () => {
const s = createNewGame(1);
const card = issueCard(s.loyalty);
card.pendingRewards.push({ kind: 'discount_percent', percent: 10 }, { kind: 'free_used_game', maxValueCents: 2000 });
stock(s, 'slot-0', tetrisLoose, 1);
const result = sell(s, catalog, 'slot-0', makeCustomer({ cardId: card.id }));
expect(result.priceCents).toBe(0);
expect(card.pendingRewards).toEqual([{ kind: 'discount_percent', percent: 10 }]);
});
it('throws before mutating when the shop cannot pay the trade excess', () => {
const s = createNewGame(1);
stock(s, 'slot-0', tetrisNew, 1);
s.cash = 100;
expect(() => trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete }), 'slot-0', 4000)).toThrow('Not enough cash');
expect(getSlot(s.inventory, 'slot-0').qty).toBe(1);
expect(s.cash).toBe(100);
expect(s.day.trades).toBe(0);
});
});
describe('changeLoyaltyProgram', () => {
it('lowers reputation by 2 when the program becomes less generous', () => {
const s = createNewGame(1);
s.reputation = 50;
changeLoyaltyProgram(s, [{ stampType: 'buy_new', slots: 20, reward: { kind: 'voucher', valueCents: 500 } }]);
expect(s.reputation).toBe(48);
expect(s.loyalty.programs).toHaveLength(2);
});
it('leaves reputation unchanged when the program is more generous', () => {
const s = createNewGame(1);
s.reputation = 50;
changeLoyaltyProgram(s, [{ stampType: 'buy_new', slots: 5, reward: { kind: 'voucher', valueCents: 1000 } }]);
expect(s.reputation).toBe(50);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"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"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
},
});