Compare commits
30
Commits
204b88fb2e
...
b62fa8006c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b62fa8006c | ||
|
|
70599d59b1 | ||
|
|
a7d3be7e1b | ||
|
|
d78e6ebd26 | ||
|
|
b1d0c4fd16 | ||
|
|
736470eb78 | ||
|
|
d978834d7e | ||
|
|
495da04b3e | ||
|
|
0de26d33f7 | ||
|
|
a677b9c61c | ||
|
|
73e5ee30b6 | ||
|
|
d66832e4a1 | ||
|
|
af10c3e8fd | ||
|
|
3a8a110a2e | ||
|
|
0fc2133e40 | ||
|
|
959f2ae537 | ||
|
|
d1fbe3b8e3 | ||
|
|
0ce9cde174 | ||
|
|
9453a936b6 | ||
|
|
923b6bf080 | ||
|
|
7ed64476f8 | ||
|
|
8c2fff96d6 | ||
|
|
d97d60bf7e | ||
|
|
df24cf7516 | ||
|
|
9765e75dd2 | ||
|
|
b2542c0020 | ||
|
|
cb103bf71a | ||
|
|
b6054086c3 | ||
|
|
13192ee0bc | ||
|
|
cb1c4177d1 |
No files matched your search
@@ -2,3 +2,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
.vitest/
|
||||
Generated
+1524
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 €');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user