From cb1c4177d1660dff6a78124ea5b074bc1d44cc7e Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:27:12 +0200 Subject: [PATCH 01/29] chore: scaffold TypeScript + Vitest --- package-lock.json | 1524 +++++++++++++++++++++++++++++++++++++++ package.json | 13 + tests/sim/smoke.test.ts | 7 + tsconfig.json | 15 + vitest.config.ts | 7 + 5 files changed, 1566 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tests/sim/smoke.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3dd4465 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1524 @@ +{ + "name": "consolgames", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "consolgames", + "devDependencies": { + "typescript": "^7.0.2", + "vitest": "^5.0.3" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..726e247 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tests/sim/smoke.test.ts b/tests/sim/smoke.test.ts new file mode 100644 index 0000000..04b41f4 --- /dev/null +++ b/tests/sim/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('smoke', () => { + it('runs', () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..874597d --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4a58023 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, +}); From 13192ee0bc5b2de2b0aadd6690fa11c7c52039ce Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:27:32 +0200 Subject: [PATCH 02/29] feat(sim): seeded PRNG --- src/sim/rng.ts | 35 +++++++++++++++++++++++++++++++ tests/sim/rng.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/sim/rng.ts create mode 100644 tests/sim/rng.test.ts diff --git a/src/sim/rng.ts b/src/sim/rng.ts new file mode 100644 index 0000000..603494e --- /dev/null +++ b/src/sim/rng.ts @@ -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( + 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; +} diff --git a/tests/sim/rng.test.ts b/tests/sim/rng.test.ts new file mode 100644 index 0000000..5ae2c96 --- /dev/null +++ b/tests/sim/rng.test.ts @@ -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(); + 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(); + }); +}); From b6054086c30c9794b37a5395212a09aa84eea659 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:27:52 +0200 Subject: [PATCH 03/29] feat(sim): calendar --- src/sim/calendar.ts | 54 ++++++++++++++++++++++++++++++++++++++ tests/sim/calendar.test.ts | 44 +++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/sim/calendar.ts create mode 100644 tests/sim/calendar.test.ts diff --git a/src/sim/calendar.ts b/src/sim/calendar.ts new file mode 100644 index 0000000..f76111e --- /dev/null +++ b/src/sim/calendar.ts @@ -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 }; +} diff --git a/tests/sim/calendar.test.ts b/tests/sim/calendar.test.ts new file mode 100644 index 0000000..4a7f4ee --- /dev/null +++ b/tests/sim/calendar.test.ts @@ -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); + }); +}); From cb103bf71aff88bd3814eedf67b9e6ce667cf062 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:28:09 +0200 Subject: [PATCH 04/29] feat(sim): franc/euro money helpers --- src/sim/money.ts | 18 ++++++++++++++++++ tests/sim/money.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 src/sim/money.ts create mode 100644 tests/sim/money.test.ts diff --git a/src/sim/money.ts b/src/sim/money.ts new file mode 100644 index 0000000..b21abcb --- /dev/null +++ b/src/sim/money.ts @@ -0,0 +1,18 @@ +import { GameDate, isEuroEra } from './calendar'; + +export const FRANCS_PER_EURO = 6.55957; + +export function francsToCents(francs: number): number { + return Math.round((francs / FRANCS_PER_EURO) * 100); +} + +export function centsToFrancs(cents: number): number { + return (cents / 100) * FRANCS_PER_EURO; +} + +export function formatMoney(cents: number, date: GameDate): string { + const euro = isEuroEra(date); + const value = euro ? cents / 100 : centsToFrancs(cents); + const text = value.toFixed(2).replace('.', ','); + return euro ? `${text} €` : `${text} F`; +} diff --git a/tests/sim/money.test.ts b/tests/sim/money.test.ts new file mode 100644 index 0000000..dd2c165 --- /dev/null +++ b/tests/sim/money.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { centsToFrancs, formatMoney, francsToCents } from '../../src/sim/money'; + +describe('money', () => { + it('converts francs to euro cents at the fixed rate', () => { + expect(francsToCents(249)).toBe(3796); + expect(francsToCents(199)).toBe(3034); + expect(francsToCents(0)).toBe(0); + }); + + it('converts cents back to francs', () => { + expect(centsToFrancs(3796)).toBeCloseTo(249, 1); + }); + + it('formats in francs before 2002', () => { + expect(formatMoney(3796, { year: 1990, month: 9, day: 28 })).toBe('249,00 F'); + }); + + it('formats in euros from 2002', () => { + expect(formatMoney(3796, { year: 2002, month: 1, day: 1 })).toBe('37,96 €'); + }); + + it('formats negative amounts', () => { + expect(formatMoney(-3796, { year: 2002, month: 1, day: 1 })).toBe('-37,96 €'); + }); +}); From b2542c0020d2d06d7587272babf2d2ded5b117de Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:28:29 +0200 Subject: [PATCH 05/29] chore: ignore .vitest --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4898416..1409076 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ .DS_Store +.vitest/ From 9765e75dd2f88b4f35e8773e64529e638a565342 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:30:21 +0200 Subject: [PATCH 06/29] feat(sim): game catalog with GB 1990-1991 data --- src/data/games.json | 24 +++++++++++++++++ src/sim/catalog.ts | 57 +++++++++++++++++++++++++++++++++++++++ tests/sim/catalog.test.ts | 37 +++++++++++++++++++++++++ tests/sim/helpers.ts | 11 ++++++++ 4 files changed, 129 insertions(+) create mode 100644 src/data/games.json create mode 100644 src/sim/catalog.ts create mode 100644 tests/sim/catalog.test.ts create mode 100644 tests/sim/helpers.ts diff --git a/src/data/games.json b/src/data/games.json new file mode 100644 index 0000000..db5da9d --- /dev/null +++ b/src/data/games.json @@ -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 } +] diff --git a/src/sim/catalog.ts b/src/sim/catalog.ts new file mode 100644 index 0000000..0c6ee48 --- /dev/null +++ b/src/sim/catalog.ts @@ -0,0 +1,57 @@ +import { compareDates, GameDate, parseISO } from './calendar'; +import { francsToCents } from './money'; +import rawGames from '../data/games.json'; + +export type ConsoleId = 'GB' | 'GBC' | 'GBA'; + +export interface Game { + id: string; + title: string; + console: ConsoleId; + releaseDate: string; // YYYY-MM-DD, sortie FR + launchPriceCents: number; + publisher: string; + genre: string; + basePopularity: number; // 0-100 + cover?: string; + uncertain?: boolean; +} + +export interface RawGame extends Omit { + launchPriceFrancs: number; +} + +export interface Catalog { + games: Game[]; + byId: Map; +} + +export function createCatalog(games: Game[]): Catalog { + const byId = new Map(); + for (const g of games) { + if (byId.has(g.id)) throw new Error(`Duplicate game id: ${g.id}`); + byId.set(g.id, g); + } + return { games, byId }; +} + +export function loadGames(raw: RawGame[]): Game[] { + return raw.map(({ launchPriceFrancs, ...rest }) => ({ + ...rest, + launchPriceCents: francsToCents(launchPriceFrancs), + })); +} + +export function loadDefaultCatalog(): Catalog { + return createCatalog(loadGames(rawGames as RawGame[])); +} + +export function getGame(catalog: Catalog, id: string): Game { + const game = catalog.byId.get(id); + if (!game) throw new Error(`Unknown game: ${id}`); + return game; +} + +export function availableGames(catalog: Catalog, date: GameDate): Game[] { + return catalog.games.filter((g) => compareDates(parseISO(g.releaseDate), date) <= 0); +} diff --git a/tests/sim/catalog.test.ts b/tests/sim/catalog.test.ts new file mode 100644 index 0000000..ef882a6 --- /dev/null +++ b/tests/sim/catalog.test.ts @@ -0,0 +1,37 @@ +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'); + }); +}); diff --git a/tests/sim/helpers.ts b/tests/sim/helpers.ts new file mode 100644 index 0000000..ca72f6f --- /dev/null +++ b/tests/sim/helpers.ts @@ -0,0 +1,11 @@ +import { createCatalog, Game } from '../../src/sim/catalog'; + +export const TEST_GAMES: Game[] = [ + { id: 'tetris', title: 'Tetris', console: 'GB', releaseDate: '1990-09-28', launchPriceCents: 3034, publisher: 'Nintendo', genre: 'Puzzle', basePopularity: 90 }, + { id: 'sml', title: 'Super Mario Land', console: 'GB', releaseDate: '1990-09-28', launchPriceCents: 3796, publisher: 'Nintendo', genre: 'Plateforme', basePopularity: 95 }, + { id: 'future', title: 'Future Game', console: 'GB', releaseDate: '1991-06-01', launchPriceCents: 3796, publisher: 'Test', genre: 'Action', basePopularity: 50 }, +]; + +export function testCatalog() { + return createCatalog(TEST_GAMES); +} From df24cf7516152326477f8651d54cf7ca8e39abbf Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:30:33 +0200 Subject: [PATCH 07/29] feat(sim): inventory with backroom and shelf slots --- src/sim/inventory.ts | 86 +++++++++++++++++++++++++++++++++++++ tests/sim/inventory.test.ts | 66 ++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/sim/inventory.ts create mode 100644 tests/sim/inventory.test.ts diff --git a/src/sim/inventory.ts b/src/sim/inventory.ts new file mode 100644 index 0000000..c84d0ec --- /dev/null +++ b/src/sim/inventory.ts @@ -0,0 +1,86 @@ +export type Condition = 'new' | 'complete' | 'no_manual' | 'loose'; + +export interface ItemRef { + gameId: string; + condition: Condition; +} + +export interface ShelfSlot { + id: string; + item: ItemRef | null; + qty: number; +} + +export interface Inventory { + backroom: Record; + slots: ShelfSlot[]; +} + +export const SLOT_CAPACITY = 5; + +export function itemKey(item: ItemRef): string { + return `${item.gameId}|${item.condition}`; +} + +export function parseItemKey(key: string): ItemRef { + const [gameId, condition] = key.split('|'); + return { gameId, condition: condition as Condition }; +} + +export function createInventory(slotCount: number): Inventory { + const slots: ShelfSlot[] = []; + for (let i = 0; i < slotCount; i++) slots.push({ id: `slot-${i}`, item: null, qty: 0 }); + return { backroom: {}, slots }; +} + +export function addToBackroom(inv: Inventory, item: ItemRef, qty: number): void { + const key = itemKey(item); + inv.backroom[key] = (inv.backroom[key] ?? 0) + qty; +} + +export function backroomQty(inv: Inventory, item: ItemRef): number { + return inv.backroom[itemKey(item)] ?? 0; +} + +export function getSlot(inv: Inventory, slotId: string): ShelfSlot { + const slot = inv.slots.find((s) => s.id === slotId); + if (!slot) throw new Error(`Unknown slot: ${slotId}`); + return slot; +} + +/** Moves up to `qty` items from the backroom into the slot. Returns the quantity moved. */ +export function stockSlot(inv: Inventory, slotId: string, item: ItemRef, qty: number): number { + const slot = getSlot(inv, slotId); + if (slot.item && itemKey(slot.item) !== itemKey(item)) { + throw new Error(`Slot ${slotId} holds another item`); + } + const key = itemKey(item); + const moved = Math.min(qty, SLOT_CAPACITY - slot.qty, inv.backroom[key] ?? 0); + if (moved <= 0) return 0; + slot.item = item; + slot.qty += moved; + inv.backroom[key] -= moved; + if (inv.backroom[key] === 0) delete inv.backroom[key]; + return moved; +} + +export function takeFromSlot(inv: Inventory, slotId: string): ItemRef { + const slot = getSlot(inv, slotId); + if (!slot.item || slot.qty === 0) throw new Error(`Slot ${slotId} is empty`); + const item = slot.item; + slot.qty -= 1; + if (slot.qty === 0) slot.item = null; + return item; +} + +export function shelfItems(inv: Inventory): ShelfSlot[] { + return inv.slots.filter((s) => s.item !== null && s.qty > 0); +} + +export function totalQty(inv: Inventory, item: ItemRef): number { + const key = itemKey(item); + const onShelves = inv.slots + .filter((s) => s.item && itemKey(s.item) === key) + .reduce((sum, s) => sum + s.qty, 0); + return backroomQty(inv, item) + onShelves; +} diff --git a/tests/sim/inventory.test.ts b/tests/sim/inventory.test.ts new file mode 100644 index 0000000..2107023 --- /dev/null +++ b/tests/sim/inventory.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { + addToBackroom, backroomQty, createInventory, getSlot, itemKey, parseItemKey, + shelfItems, SLOT_CAPACITY, stockSlot, takeFromSlot, totalQty, +} from '../../src/sim/inventory'; + +const tetrisNew = { gameId: 'tetris', condition: 'new' as const }; +const smlLoose = { gameId: 'sml', condition: 'loose' as const }; + +describe('inventory', () => { + it('round-trips item keys', () => { + expect(parseItemKey(itemKey(smlLoose))).toEqual(smlLoose); + }); + + it('creates empty slots', () => { + const inv = createInventory(3); + expect(inv.slots.map((s) => s.id)).toEqual(['slot-0', 'slot-1', 'slot-2']); + expect(shelfItems(inv)).toEqual([]); + }); + + it('adds to the backroom', () => { + const inv = createInventory(1); + addToBackroom(inv, tetrisNew, 3); + addToBackroom(inv, tetrisNew, 2); + expect(backroomQty(inv, tetrisNew)).toBe(5); + }); + + it('stocks a slot up to capacity from the backroom', () => { + const inv = createInventory(1); + addToBackroom(inv, tetrisNew, SLOT_CAPACITY + 2); + const moved = stockSlot(inv, 'slot-0', tetrisNew, 100); + expect(moved).toBe(SLOT_CAPACITY); + expect(getSlot(inv, 'slot-0')).toEqual({ id: 'slot-0', item: tetrisNew, qty: SLOT_CAPACITY }); + expect(backroomQty(inv, tetrisNew)).toBe(2); + expect(totalQty(inv, tetrisNew)).toBe(SLOT_CAPACITY + 2); + }); + + it('moves nothing when the backroom is empty', () => { + const inv = createInventory(1); + expect(stockSlot(inv, 'slot-0', tetrisNew, 3)).toBe(0); + expect(getSlot(inv, 'slot-0').item).toBeNull(); + }); + + it('refuses mixing items in one slot', () => { + const inv = createInventory(1); + addToBackroom(inv, tetrisNew, 1); + addToBackroom(inv, smlLoose, 1); + stockSlot(inv, 'slot-0', tetrisNew, 1); + expect(() => stockSlot(inv, 'slot-0', smlLoose, 1)).toThrow('Slot slot-0 holds another item'); + }); + + it('takes items from a slot and clears it when empty', () => { + const inv = createInventory(1); + addToBackroom(inv, tetrisNew, 2); + stockSlot(inv, 'slot-0', tetrisNew, 2); + expect(takeFromSlot(inv, 'slot-0')).toEqual(tetrisNew); + expect(getSlot(inv, 'slot-0').qty).toBe(1); + takeFromSlot(inv, 'slot-0'); + expect(getSlot(inv, 'slot-0')).toEqual({ id: 'slot-0', item: null, qty: 0 }); + expect(() => takeFromSlot(inv, 'slot-0')).toThrow('Slot slot-0 is empty'); + }); + + it('throws on unknown slot', () => { + expect(() => getSlot(createInventory(1), 'slot-9')).toThrow('Unknown slot: slot-9'); + }); +}); From d97d60bf7ec1732cba46c2d628a003ce0532d8f4 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:30:36 +0200 Subject: [PATCH 08/29] feat(sim): economy rules --- src/sim/economy.ts | 40 ++++++++++++++++++++++++++++++++++ tests/sim/economy.test.ts | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 src/sim/economy.ts create mode 100644 tests/sim/economy.test.ts diff --git a/src/sim/economy.ts b/src/sim/economy.ts new file mode 100644 index 0000000..7090f5d --- /dev/null +++ b/src/sim/economy.ts @@ -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, number> = { + complete: 0.6, + no_manual: 0.5, + loose: 0.4, +}; +export const CASH_BUYBACK_RATE = 0.35; +export const TRADE_BUYBACK_RATE = 0.5; +export const MONTHLY_RENT_CENTS = francsToCents(6000); +export const STARTING_CASH_CENTS = francsToCents(30000); + +export type BuyBackMode = 'cash' | 'trade'; + +export function wholesaleCost(game: Game): number { + return Math.round(game.launchPriceCents * WHOLESALE_RATE); +} + +export function referencePrice(game: Game, condition: Condition): number { + if (condition === 'new') return game.launchPriceCents; + return Math.round(game.launchPriceCents * USED_FACTOR[condition]); +} + +/** 0.5 at the reference price, 1 at half price or less, 0 at 150% or more. */ +export function purchaseProbability(priceCents: number, referenceCents: number): number { + const ratio = priceCents / referenceCents; + return Math.min(1, Math.max(0, 1.5 - ratio)); +} + +export function customerMinimumAsk(game: Game, condition: Condition, mode: BuyBackMode): number { + const rate = mode === 'cash' ? CASH_BUYBACK_RATE : TRADE_BUYBACK_RATE; + return Math.round(referencePrice(game, condition) * rate); +} + +export function priceOf(prices: Record, catalog: Catalog, item: ItemRef): number { + return prices[itemKey(item)] ?? referencePrice(getGame(catalog, item.gameId), item.condition); +} diff --git a/tests/sim/economy.test.ts b/tests/sim/economy.test.ts new file mode 100644 index 0000000..a9e5048 --- /dev/null +++ b/tests/sim/economy.test.ts @@ -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); + }); +}); From 8c2fff96d651cbd40173a40aeda3e410b0fbe3d7 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:30:40 +0200 Subject: [PATCH 09/29] feat(sim): release demand curve --- src/sim/demand.ts | 15 +++++++++++++++ tests/sim/demand.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/sim/demand.ts create mode 100644 tests/sim/demand.test.ts diff --git a/src/sim/demand.ts b/src/sim/demand.ts new file mode 100644 index 0000000..ab2b6d0 --- /dev/null +++ b/src/sim/demand.ts @@ -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); +} diff --git a/tests/sim/demand.test.ts b/tests/sim/demand.test.ts new file mode 100644 index 0000000..265f0d0 --- /dev/null +++ b/tests/sim/demand.test.ts @@ -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); + }); +}); From 7ed64476f8220b96c75870926168ecca9b285d1d Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:32:18 +0200 Subject: [PATCH 10/29] feat(sim): configurable loyalty program --- src/sim/loyalty.ts | 124 ++++++++++++++++++++++++++++++++++++++ tests/sim/loyalty.test.ts | 94 +++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 src/sim/loyalty.ts create mode 100644 tests/sim/loyalty.test.ts diff --git a/src/sim/loyalty.ts b/src/sim/loyalty.ts new file mode 100644 index 0000000..b00c773 --- /dev/null +++ b/src/sim/loyalty.ts @@ -0,0 +1,124 @@ +export type StampType = 'buy_new' | 'buy_used' | 'trade' | 'buy_back'; + +export type Reward = + | { kind: 'free_trade' } + | { kind: 'free_used_game'; maxValueCents: number } + | { kind: 'discount_percent'; percent: number } + | { kind: 'voucher'; valueCents: number }; + +export interface StampTrack { + stampType: StampType; + slots: number; + reward: Reward; +} + +export interface LoyaltyProgram { + version: number; + tracks: StampTrack[]; +} + +export interface LoyaltyCard { + id: string; + programVersion: number; + stamps: number[]; // one counter per track of its program + pendingRewards: Reward[]; + voucherCents: number; +} + +export interface LoyaltyState { + programs: LoyaltyProgram[]; + cards: Record; + nextCardNumber: number; +} + +export const DEFAULT_TRACKS: StampTrack[] = [ + { stampType: 'buy_new', slots: 10, reward: { kind: 'free_used_game', maxValueCents: 1000 } }, +]; + +/** Estimated cost of a reward, used to rate how generous a program is. */ +const FREE_TRADE_VALUE_CENTS = 800; +const AVERAGE_BASKET_CENTS = 3000; + +export function createLoyaltyState(): LoyaltyState { + return { programs: [{ version: 1, tracks: DEFAULT_TRACKS }], cards: {}, nextCardNumber: 1 }; +} + +export function currentProgram(l: LoyaltyState): LoyaltyProgram { + return l.programs[l.programs.length - 1]; +} + +export function getProgram(l: LoyaltyState, version: number): LoyaltyProgram { + const program = l.programs.find((p) => p.version === version); + if (!program) throw new Error(`Unknown loyalty program version: ${version}`); + return program; +} + +export function validateTracks(tracks: StampTrack[]): string | null { + if (tracks.length < 1 || tracks.length > 2) return '1 ou 2 types de tampons'; + if (new Set(tracks.map((t) => t.stampType)).size !== tracks.length) return 'Types de tampons en double'; + if (tracks.some((t) => t.slots < 3 || t.slots > 20)) return 'Entre 3 et 20 cases par type'; + return null; +} + +export function rewardValueCents(r: Reward): number { + switch (r.kind) { + case 'free_trade': return FREE_TRADE_VALUE_CENTS; + case 'free_used_game': return r.maxValueCents; + case 'discount_percent': return (AVERAGE_BASKET_CENTS * r.percent) / 100; + case 'voucher': return r.valueCents; + } +} + +/** Reward value given back per stamp, in cents. */ +export function generosity(program: LoyaltyProgram): number { + return program.tracks.reduce((sum, t) => sum + rewardValueCents(t.reward) / t.slots, 0); +} + +export function returnVisitMultiplier(program: LoyaltyProgram): number { + return 1 + Math.min(generosity(program) / 100, 1); +} + +export function joinProbability(program: LoyaltyProgram): number { + return Math.min(0.15 + generosity(program) / 1000, 0.6); +} + +export function setProgram(l: LoyaltyState, tracks: StampTrack[]): { reputationDelta: number } { + const error = validateTracks(tracks); + if (error) throw new Error(error); + const previous = currentProgram(l); + const next: LoyaltyProgram = { version: previous.version + 1, tracks }; + l.programs.push(next); + return { reputationDelta: generosity(next) < generosity(previous) ? -2 : 0 }; +} + +export function issueCard(l: LoyaltyState): LoyaltyCard { + const program = currentProgram(l); + const card: LoyaltyCard = { + id: `C${String(l.nextCardNumber).padStart(4, '0')}`, + programVersion: program.version, + stamps: program.tracks.map(() => 0), + pendingRewards: [], + voucherCents: 0, + }; + l.nextCardNumber += 1; + l.cards[card.id] = card; + return card; +} + +/** Stamps every track of the card matching `type`. Returns the rewards earned. */ +export function addStamp(l: LoyaltyState, cardId: string, type: StampType): Reward[] { + const card = l.cards[cardId]; + if (!card) throw new Error(`Unknown card: ${cardId}`); + const program = getProgram(l, card.programVersion); + const earned: Reward[] = []; + program.tracks.forEach((track, i) => { + if (track.stampType !== type) return; + card.stamps[i] += 1; + if (card.stamps[i] < track.slots) return; + card.stamps[i] = 0; + earned.push(track.reward); + if (track.reward.kind === 'voucher') card.voucherCents += track.reward.valueCents; + else card.pendingRewards.push(track.reward); + }); + return earned; +} diff --git a/tests/sim/loyalty.test.ts b/tests/sim/loyalty.test.ts new file mode 100644 index 0000000..9c1ece2 --- /dev/null +++ b/tests/sim/loyalty.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { + addStamp, createLoyaltyState, currentProgram, generosity, issueCard, + joinProbability, returnVisitMultiplier, setProgram, StampTrack, validateTracks, +} from '../../src/sim/loyalty'; + +describe('loyalty', () => { + it('starts with 10 purchase stamps for a used game up to 10 €', () => { + const l = createLoyaltyState(); + expect(currentProgram(l)).toEqual({ + version: 1, + tracks: [{ stampType: 'buy_new', slots: 10, reward: { kind: 'free_used_game', maxValueCents: 1000 } }], + }); + expect(generosity(currentProgram(l))).toBe(100); + expect(returnVisitMultiplier(currentProgram(l))).toBe(2); + expect(joinProbability(currentProgram(l))).toBeCloseTo(0.25); + }); + + it('issues numbered cards on the current program', () => { + const l = createLoyaltyState(); + const card = issueCard(l); + expect(card).toEqual({ id: 'C0001', programVersion: 1, stamps: [0], pendingRewards: [], voucherCents: 0 }); + expect(issueCard(l).id).toBe('C0002'); + }); + + it('earns the reward on the last stamp and resets the track', () => { + const l = createLoyaltyState(); + const card = issueCard(l); + for (let i = 0; i < 9; i++) expect(addStamp(l, card.id, 'buy_new')).toEqual([]); + expect(addStamp(l, card.id, 'buy_new')).toEqual([{ kind: 'free_used_game', maxValueCents: 1000 }]); + expect(card.stamps).toEqual([0]); + expect(card.pendingRewards).toHaveLength(1); + }); + + it('ignores stamp types the card does not track', () => { + const l = createLoyaltyState(); + const card = issueCard(l); + expect(addStamp(l, card.id, 'trade')).toEqual([]); + expect(card.stamps).toEqual([0]); + }); + + it('supports two independent stamp tracks', () => { + const l = createLoyaltyState(); + setProgram(l, [ + { stampType: 'buy_new', slots: 3, reward: { kind: 'discount_percent', percent: 20 } }, + { stampType: 'trade', slots: 3, reward: { kind: 'free_trade' } }, + ]); + const card = issueCard(l); + addStamp(l, card.id, 'buy_new'); + addStamp(l, card.id, 'trade'); + addStamp(l, card.id, 'trade'); + expect(card.stamps).toEqual([1, 2]); + expect(addStamp(l, card.id, 'trade')).toEqual([{ kind: 'free_trade' }]); + }); + + it('turns voucher rewards into card credit', () => { + const l = createLoyaltyState(); + setProgram(l, [{ stampType: 'buy_used', slots: 3, reward: { kind: 'voucher', valueCents: 500 } }]); + const card = issueCard(l); + for (let i = 0; i < 3; i++) addStamp(l, card.id, 'buy_used'); + expect(card.voucherCents).toBe(500); + expect(card.pendingRewards).toEqual([]); + }); + + it('validates tracks', () => { + const reward = { kind: 'free_trade' } as const; + const t = (stampType: StampTrack['stampType'], slots: number): StampTrack => ({ stampType, slots, reward }); + expect(validateTracks([])).toBe('1 ou 2 types de tampons'); + expect(validateTracks([t('buy_new', 5), t('trade', 5), t('buy_back', 5)])).toBe('1 ou 2 types de tampons'); + expect(validateTracks([t('buy_new', 5), t('buy_new', 5)])).toBe('Types de tampons en double'); + expect(validateTracks([t('buy_new', 2)])).toBe('Entre 3 et 20 cases par type'); + expect(validateTracks([t('buy_new', 21)])).toBe('Entre 3 et 20 cases par type'); + expect(validateTracks([t('buy_new', 3)])).toBeNull(); + expect(() => setProgram(createLoyaltyState(), [])).toThrow('1 ou 2 types de tampons'); + }); + + it('keeps existing cards on the rules they were issued with', () => { + const l = createLoyaltyState(); + const oldCard = issueCard(l); + setProgram(l, [{ stampType: 'buy_new', slots: 3, reward: { kind: 'voucher', valueCents: 500 } }]); + for (let i = 0; i < 3; i++) addStamp(l, oldCard.id, 'buy_new'); + expect(oldCard.stamps).toEqual([3]); + expect(oldCard.voucherCents).toBe(0); + expect(issueCard(l).programVersion).toBe(2); + }); + + it('penalizes reputation when the program becomes less generous', () => { + const l = createLoyaltyState(); + const lessGenerous = setProgram(l, [{ stampType: 'buy_new', slots: 20, reward: { kind: 'voucher', valueCents: 500 } }]); + expect(lessGenerous.reputationDelta).toBe(-2); + const moreGenerous = setProgram(l, [{ stampType: 'buy_new', slots: 5, reward: { kind: 'voucher', valueCents: 1000 } }]); + expect(moreGenerous.reputationDelta).toBe(0); + }); +}); From 923b6bf0807d9b98006f8c28f8d619492834613f Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:32:33 +0200 Subject: [PATCH 11/29] feat(sim): game state --- src/sim/state.ts | 74 +++++++++++++++++++++++++++++++++++++++++ tests/sim/state.test.ts | 25 ++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/sim/state.ts create mode 100644 tests/sim/state.test.ts diff --git a/src/sim/state.ts b/src/sim/state.ts new file mode 100644 index 0000000..03307b9 --- /dev/null +++ b/src/sim/state.ts @@ -0,0 +1,74 @@ +import { GameDate, START_DATE } from './calendar'; +import { STARTING_CASH_CENTS } from './economy'; +import { createInventory, Inventory, ItemRef, itemKey } from './inventory'; +import { createLoyaltyState, LoyaltyState } from './loyalty'; + +export const DAY_START_MINUTES = 8 * 60; +export const OPEN_MINUTES = 9 * 60; +export const CLOSE_MINUTES = 19 * 60; +export const SHELF_SLOT_COUNT = 12; + +export interface DayStats { + revenueCents: number; + expensesCents: number; + salesCount: number; + lostRequests: number; + buyBacks: number; + trades: number; +} + +export interface PendingOrder { + item: ItemRef; + qty: number; +} + +export interface GameState { + saveVersion: 1; + date: GameDate; + minutes: number; + cash: number; + reputation: number; // 0-100 + inventory: Inventory; + prices: Record; + loyalty: LoyaltyState; + pendingOrders: PendingOrder[]; + day: DayStats; + negativeMonthEnds: number; + bankrupt: boolean; + rngState: number; + nextCustomerId: number; + customerAccumulator: number; +} + +export function emptyDayStats(): DayStats { + return { revenueCents: 0, expensesCents: 0, salesCount: 0, lostRequests: 0, buyBacks: 0, trades: 0 }; +} + +export function createNewGame(seed: number): GameState { + return { + saveVersion: 1, + date: { ...START_DATE }, + minutes: DAY_START_MINUTES, + cash: STARTING_CASH_CENTS, + reputation: 20, + inventory: createInventory(SHELF_SLOT_COUNT), + prices: {}, + loyalty: createLoyaltyState(), + pendingOrders: [], + day: emptyDayStats(), + negativeMonthEnds: 0, + bankrupt: false, + rngState: seed, + nextCustomerId: 1, + customerAccumulator: 0, + }; +} + +export function setPrice(state: GameState, item: ItemRef, cents: number): void { + if (cents <= 0) throw new Error('Price must be positive'); + state.prices[itemKey(item)] = cents; +} + +export function adjustReputation(state: GameState, delta: number): void { + state.reputation = Math.min(100, Math.max(0, state.reputation + delta)); +} diff --git a/tests/sim/state.test.ts b/tests/sim/state.test.ts new file mode 100644 index 0000000..ce5fb83 --- /dev/null +++ b/tests/sim/state.test.ts @@ -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'); + }); +}); From 9453a936b63174f58e105e7df1e41ba205f9a819 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:32:50 +0200 Subject: [PATCH 12/29] feat(sim): wholesaler orders and deliveries --- src/sim/orders.ts | 25 +++++++++++++++++++++++++ tests/sim/orders.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/sim/orders.ts create mode 100644 tests/sim/orders.test.ts diff --git a/src/sim/orders.ts b/src/sim/orders.ts new file mode 100644 index 0000000..0c28ad8 --- /dev/null +++ b/src/sim/orders.ts @@ -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; +} diff --git a/tests/sim/orders.test.ts b/tests/sim/orders.test.ts new file mode 100644 index 0000000..93156ee --- /dev/null +++ b/tests/sim/orders.test.ts @@ -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([]); + }); +}); From 0ce9cde1741618d67425cd1b1f723333f0e7f557 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:34:41 +0200 Subject: [PATCH 13/29] fix(sim): migrate completed loyalty cards to the current program --- src/sim/loyalty.ts | 23 ++++++++++++++++++++--- tests/sim/loyalty.test.ts | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/sim/loyalty.ts b/src/sim/loyalty.ts index b00c773..0066bea 100644 --- a/src/sim/loyalty.ts +++ b/src/sim/loyalty.ts @@ -40,7 +40,7 @@ const FREE_TRADE_VALUE_CENTS = 800; const AVERAGE_BASKET_CENTS = 3000; export function createLoyaltyState(): LoyaltyState { - return { programs: [{ version: 1, tracks: DEFAULT_TRACKS }], cards: {}, nextCardNumber: 1 }; + return { programs: [{ version: 1, tracks: structuredClone(DEFAULT_TRACKS) }], cards: {}, nextCardNumber: 1 }; } export function currentProgram(l: LoyaltyState): LoyaltyProgram { @@ -56,10 +56,20 @@ export function getProgram(l: LoyaltyState, version: number): LoyaltyProgram { export function validateTracks(tracks: StampTrack[]): string | null { if (tracks.length < 1 || tracks.length > 2) return '1 ou 2 types de tampons'; if (new Set(tracks.map((t) => t.stampType)).size !== tracks.length) return 'Types de tampons en double'; - if (tracks.some((t) => t.slots < 3 || t.slots > 20)) return 'Entre 3 et 20 cases par type'; + 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; @@ -86,7 +96,7 @@ export function setProgram(l: LoyaltyState, tracks: StampTrack[]): { reputationD const error = validateTracks(tracks); if (error) throw new Error(error); const previous = currentProgram(l); - const next: LoyaltyProgram = { version: previous.version + 1, tracks }; + const next: LoyaltyProgram = { version: previous.version + 1, tracks: structuredClone(tracks) }; l.programs.push(next); return { reputationDelta: generosity(next) < generosity(previous) ? -2 : 0 }; } @@ -111,14 +121,21 @@ export function addStamp(l: LoyaltyState, cardId: string, type: StampType): Rewa 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.programVersion = current.version; + card.stamps = current.tracks.map(() => 0); + } return earned; } diff --git a/tests/sim/loyalty.test.ts b/tests/sim/loyalty.test.ts index 9c1ece2..94a347e 100644 --- a/tests/sim/loyalty.test.ts +++ b/tests/sim/loyalty.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - addStamp, createLoyaltyState, currentProgram, generosity, issueCard, + addStamp, createLoyaltyState, currentProgram, DEFAULT_TRACKS, generosity, issueCard, joinProbability, returnVisitMultiplier, setProgram, StampTrack, validateTracks, } from '../../src/sim/loyalty'; @@ -91,4 +91,39 @@ describe('loyalty', () => { 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(); + }); }); From d1fbe3b8e3d86f8ce9aa9f8264b949f5ef6703fa Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:35:06 +0200 Subject: [PATCH 14/29] feat(sim): customer generation and shelf choice --- src/sim/customers.ts | 81 +++++++++++++++++++++++++++++++++++++ tests/sim/customers.test.ts | 74 +++++++++++++++++++++++++++++++++ tests/sim/helpers.ts | 13 ++++++ 3 files changed, 168 insertions(+) create mode 100644 src/sim/customers.ts create mode 100644 tests/sim/customers.test.ts diff --git a/src/sim/customers.ts b/src/sim/customers.ts new file mode 100644 index 0000000..fa94d4d --- /dev/null +++ b/src/sim/customers.ts @@ -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; +} diff --git a/tests/sim/customers.test.ts b/tests/sim/customers.test.ts new file mode 100644 index 0000000..4fea038 --- /dev/null +++ b/tests/sim/customers.test.ts @@ -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(); + }); +}); diff --git a/tests/sim/helpers.ts b/tests/sim/helpers.ts index ca72f6f..741ee55 100644 --- a/tests/sim/helpers.ts +++ b/tests/sim/helpers.ts @@ -1,4 +1,7 @@ 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 }, @@ -9,3 +12,13 @@ export const TEST_GAMES: Game[] = [ export function testCatalog() { return createCatalog(TEST_GAMES); } + +export function makeCustomer(partial: Partial = {}): Customer { + return { id: 1, intent: 'buy', cardId: null, wantedGameId: null, offered: null, ...partial }; +} + +/** Puts `qty` items straight onto a slot. */ +export function stock(state: GameState, slotId: string, item: ItemRef, qty: number): void { + addToBackroom(state.inventory, item, qty); + stockSlot(state.inventory, slotId, item, qty); +} From 959f2ae5370d215bf02f0ee3a7ac12c31cacb49f Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:35:16 +0200 Subject: [PATCH 15/29] feat(sim): sales, buy-backs, trades and requests --- src/sim/transactions.ts | 133 ++++++++++++++++++++++++++ tests/sim/transactions.test.ts | 169 +++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 src/sim/transactions.ts create mode 100644 tests/sim/transactions.test.ts diff --git a/src/sim/transactions.ts b/src/sim/transactions.ts new file mode 100644 index 0000000..bb31367 --- /dev/null +++ b/src/sim/transactions.ts @@ -0,0 +1,133 @@ +import { Catalog, getGame } from './catalog'; +import { customerMinimumAsk, priceOf } from './economy'; +import { Customer } from './customers'; +import { addToBackroom, ItemRef, takeFromSlot } from './inventory'; +import { addStamp, currentProgram, issueCard, joinProbability, Reward, StampType } from './loyalty'; +import { random } from './rng'; +import { adjustReputation, GameState } from './state'; + +export interface SaleResult { + item: ItemRef; + priceCents: number; + rewardUsed: Reward | null; + rewardsEarned: Reward[]; +} + +export interface BuyBackResult { + accepted: boolean; +} + +export interface TradeResult { + accepted: boolean; + customerPaysCents: number; + shopPaysCents: number; + voucherGivenCents: number; +} + +function stampCard(state: GameState, customer: Customer, type: StampType): Reward[] { + if (!customer.cardId) return []; + return addStamp(state.loyalty, customer.cardId, type); +} + +function maybeJoin(state: GameState, customer: Customer): void { + if (customer.cardId) return; + if (random(state) < joinProbability(currentProgram(state.loyalty))) { + customer.cardId = issueCard(state.loyalty).id; + } +} + +function takeReward(state: GameState, customer: Customer, applies: (r: Reward) => boolean): Reward | null { + if (!customer.cardId) return null; + const card = state.loyalty.cards[customer.cardId]; + const index = card.pendingRewards.findIndex(applies); + if (index === -1) return null; + return card.pendingRewards.splice(index, 1)[0]; +} + +function offeredItem(customer: Customer): ItemRef { + if (!customer.offered) throw new Error(`Customer ${customer.id} has nothing to sell`); + return customer.offered; +} + +export function sell(state: GameState, catalog: Catalog, slotId: string, customer: Customer): SaleResult { + const item = takeFromSlot(state.inventory, slotId); + let price = priceOf(state.prices, catalog, item); + + const rewardUsed = takeReward(state, customer, (r) => + (r.kind === 'free_used_game' && item.condition !== 'new' && price <= r.maxValueCents) || + r.kind === 'discount_percent', + ); + if (rewardUsed?.kind === 'free_used_game') price = 0; + if (rewardUsed?.kind === 'discount_percent') price = Math.round((price * (100 - rewardUsed.percent)) / 100); + + if (customer.cardId) { + const card = state.loyalty.cards[customer.cardId]; + const fromVoucher = Math.min(card.voucherCents, price); + card.voucherCents -= fromVoucher; + price -= fromVoucher; + } + + state.cash += price; + state.day.revenueCents += price; + state.day.salesCount += 1; + adjustReputation(state, 0.1); + + maybeJoin(state, customer); + const rewardsEarned = stampCard(state, customer, item.condition === 'new' ? 'buy_new' : 'buy_used'); + return { item, priceCents: price, rewardUsed, rewardsEarned }; +} + +export function buyBack(state: GameState, catalog: Catalog, customer: Customer, offerCents: number): BuyBackResult { + const item = offeredItem(customer); + const minimum = customerMinimumAsk(getGame(catalog, item.gameId), item.condition, 'cash'); + if (offerCents < minimum) return { accepted: false }; + if (offerCents > state.cash) throw new Error('Not enough cash'); + state.cash -= offerCents; + state.day.expensesCents += offerCents; + state.day.buyBacks += 1; + addToBackroom(state.inventory, item, 1); + stampCard(state, customer, 'buy_back'); + return { accepted: true }; +} + +export function trade( + state: GameState, catalog: Catalog, customer: Customer, slotId: string, creditCents: number, +): TradeResult { + const offered = offeredItem(customer); + const minimum = customerMinimumAsk(getGame(catalog, offered.gameId), offered.condition, 'trade'); + if (creditCents < minimum) return { accepted: false, customerPaysCents: 0, shopPaysCents: 0, voucherGivenCents: 0 }; + + const wanted = takeFromSlot(state.inventory, slotId); + const difference = priceOf(state.prices, catalog, wanted) - creditCents; + let customerPaysCents = 0; + let shopPaysCents = 0; + let voucherGivenCents = 0; + + if (difference >= 0) { + const freeTrade = takeReward(state, customer, (r) => r.kind === 'free_trade'); + customerPaysCents = freeTrade ? 0 : difference; + state.cash += customerPaysCents; + state.day.revenueCents += customerPaysCents; + } else if (customer.cardId) { + voucherGivenCents = -difference; + state.loyalty.cards[customer.cardId].voucherCents += voucherGivenCents; + } else { + shopPaysCents = -difference; + state.cash -= shopPaysCents; + state.day.expensesCents += shopPaysCents; + } + + addToBackroom(state.inventory, offered, 1); + state.day.trades += 1; + stampCard(state, customer, 'trade'); + return { accepted: true, customerPaysCents, shopPaysCents, voucherGivenCents }; +} + +export function recordLostRequest(state: GameState): void { + state.day.lostRequests += 1; + adjustReputation(state, -1); +} + +export function recordSatisfiedRequest(state: GameState): void { + adjustReputation(state, 0.5); +} diff --git a/tests/sim/transactions.test.ts b/tests/sim/transactions.test.ts new file mode 100644 index 0000000..5a20e2d --- /dev/null +++ b/tests/sim/transactions.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from 'vitest'; +import { buyBack, recordLostRequest, recordSatisfiedRequest, sell, trade } from '../../src/sim/transactions'; +import { createNewGame } from '../../src/sim/state'; +import { backroomQty, getSlot } from '../../src/sim/inventory'; +import { issueCard, setProgram } from '../../src/sim/loyalty'; +import { makeCustomer, stock, testCatalog } from './helpers'; + +const catalog = testCatalog(); +const tetrisNew = { gameId: 'tetris', condition: 'new' as const }; +const tetrisLoose = { gameId: 'tetris', condition: 'loose' as const }; +const smlComplete = { gameId: 'sml', condition: 'complete' as const }; + +describe('sell', () => { + it('takes the item, cashes the price and records the sale', () => { + const s = createNewGame(1); + stock(s, 'slot-0', tetrisNew, 2); + const cash = s.cash; + const result = sell(s, catalog, 'slot-0', makeCustomer()); + expect(result.priceCents).toBe(3034); + expect(s.cash).toBe(cash + 3034); + expect(getSlot(s.inventory, 'slot-0').qty).toBe(1); + expect(s.day.salesCount).toBe(1); + expect(s.day.revenueCents).toBe(3034); + expect(s.reputation).toBeCloseTo(20.1); + }); + + it('stamps the card of a card holder', () => { + const s = createNewGame(1); + const card = issueCard(s.loyalty); + stock(s, 'slot-0', tetrisNew, 1); + sell(s, catalog, 'slot-0', makeCustomer({ cardId: card.id })); + expect(card.stamps).toEqual([1]); + }); + + it('applies a free used game reward', () => { + const s = createNewGame(1); + const card = issueCard(s.loyalty); + card.pendingRewards.push({ kind: 'free_used_game', maxValueCents: 2000 }); + stock(s, 'slot-0', tetrisLoose, 1); + const cash = s.cash; + const result = sell(s, catalog, 'slot-0', makeCustomer({ cardId: card.id })); + expect(result.priceCents).toBe(0); + expect(result.rewardUsed).toEqual({ kind: 'free_used_game', maxValueCents: 2000 }); + expect(s.cash).toBe(cash); + expect(card.pendingRewards).toEqual([]); + }); + + it('applies a percentage discount', () => { + const s = createNewGame(1); + const card = issueCard(s.loyalty); + card.pendingRewards.push({ kind: 'discount_percent', percent: 50 }); + stock(s, 'slot-0', tetrisNew, 1); + expect(sell(s, catalog, 'slot-0', makeCustomer({ cardId: card.id })).priceCents).toBe(1517); + }); + + it('spends the card voucher', () => { + const s = createNewGame(1); + const card = issueCard(s.loyalty); + card.voucherCents = 1000; + stock(s, 'slot-0', tetrisNew, 1); + const cash = s.cash; + expect(sell(s, catalog, 'slot-0', makeCustomer({ cardId: card.id })).priceCents).toBe(2034); + expect(s.cash).toBe(cash + 2034); + expect(card.voucherCents).toBe(0); + }); +}); + +describe('buyBack', () => { + it('is refused below what the customer accepts', () => { + const s = createNewGame(1); + const cash = s.cash; + expect(buyBack(s, catalog, makeCustomer({ intent: 'buy_back', offered: smlComplete }), 796)).toEqual({ accepted: false }); + expect(s.cash).toBe(cash); + }); + + it('buys the game into the backroom when accepted', () => { + const s = createNewGame(1); + const cash = s.cash; + expect(buyBack(s, catalog, makeCustomer({ intent: 'buy_back', offered: smlComplete }), 797)).toEqual({ accepted: true }); + expect(s.cash).toBe(cash - 797); + expect(backroomQty(s.inventory, smlComplete)).toBe(1); + expect(s.day.buyBacks).toBe(1); + expect(s.day.expensesCents).toBe(797); + }); + + it('throws when the shop cannot pay', () => { + const s = createNewGame(1); + s.cash = 100; + expect(() => buyBack(s, catalog, makeCustomer({ intent: 'buy_back', offered: smlComplete }), 797)).toThrow('Not enough cash'); + }); + + it('stamps buy_back cards', () => { + const s = createNewGame(1); + setProgram(s.loyalty, [{ stampType: 'buy_back', slots: 5, reward: { kind: 'free_trade' } }]); + const card = issueCard(s.loyalty); + buyBack(s, catalog, makeCustomer({ intent: 'buy_back', offered: smlComplete, cardId: card.id }), 797); + expect(card.stamps).toEqual([1]); + }); +}); + +describe('trade', () => { + it('is refused below the trade value the customer expects', () => { + const s = createNewGame(1); + stock(s, 'slot-0', tetrisNew, 1); + const result = trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete }), 'slot-0', 1138); + expect(result.accepted).toBe(false); + expect(getSlot(s.inventory, 'slot-0').qty).toBe(1); + }); + + it('makes the customer pay the difference', () => { + const s = createNewGame(1); + stock(s, 'slot-0', tetrisNew, 1); + const cash = s.cash; + const result = trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete }), 'slot-0', 1139); + expect(result).toEqual({ accepted: true, customerPaysCents: 1895, shopPaysCents: 0, voucherGivenCents: 0 }); + expect(s.cash).toBe(cash + 1895); + expect(backroomQty(s.inventory, smlComplete)).toBe(1); + expect(getSlot(s.inventory, 'slot-0').qty).toBe(0); + expect(s.day.trades).toBe(1); + }); + + it('pays the excess in cash to customers without a card', () => { + const s = createNewGame(1); + stock(s, 'slot-0', tetrisNew, 1); + const cash = s.cash; + const result = trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete }), 'slot-0', 4000); + expect(result).toEqual({ accepted: true, customerPaysCents: 0, shopPaysCents: 966, voucherGivenCents: 0 }); + expect(s.cash).toBe(cash - 966); + }); + + it('gives the excess as a voucher to card holders', () => { + const s = createNewGame(1); + const card = issueCard(s.loyalty); + stock(s, 'slot-0', tetrisNew, 1); + const cash = s.cash; + const result = trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete, cardId: card.id }), 'slot-0', 4000); + expect(result.voucherGivenCents).toBe(966); + expect(card.voucherCents).toBe(966); + expect(s.cash).toBe(cash); + }); + + it('waives the difference with a free trade reward', () => { + const s = createNewGame(1); + const card = issueCard(s.loyalty); + card.pendingRewards.push({ kind: 'free_trade' }); + stock(s, 'slot-0', tetrisNew, 1); + const result = trade(s, catalog, makeCustomer({ intent: 'trade', offered: smlComplete, cardId: card.id }), 'slot-0', 1139); + expect(result.customerPaysCents).toBe(0); + expect(card.pendingRewards).toEqual([]); + }); +}); + +describe('requests', () => { + it('costs reputation when the game is missing, clamped at 0', () => { + const s = createNewGame(1); + recordLostRequest(s); + expect(s.reputation).toBe(19); + expect(s.day.lostRequests).toBe(1); + s.reputation = 0.5; + recordLostRequest(s); + expect(s.reputation).toBe(0); + }); + + it('earns reputation when the game is found', () => { + const s = createNewGame(1); + recordSatisfiedRequest(s); + expect(s.reputation).toBe(20.5); + }); +}); From 0fc2133e40919ad8fdec9b90bc41693a8f2399de Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:38:06 +0200 Subject: [PATCH 16/29] fix(sim): loyalty migration keeps stamps, reward edge cases --- src/sim/catalog.ts | 1 + src/sim/loyalty.ts | 5 ++++- src/sim/transactions.ts | 17 +++++++++++------ tests/sim/catalog.test.ts | 4 ++++ tests/sim/loyalty.test.ts | 17 +++++++++++++++++ tests/sim/transactions.test.ts | 31 +++++++++++++++++++++++++++++++ 6 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/sim/catalog.ts b/src/sim/catalog.ts index 0c6ee48..8e2780d 100644 --- a/src/sim/catalog.ts +++ b/src/sim/catalog.ts @@ -30,6 +30,7 @@ export function createCatalog(games: Game[]): Catalog { const byId = new Map(); for (const g of games) { if (byId.has(g.id)) throw new Error(`Duplicate game id: ${g.id}`); + if (g.launchPriceCents <= 0) throw new Error(`Invalid launch price: ${g.id}`); byId.set(g.id, g); } return { games, byId }; diff --git a/src/sim/loyalty.ts b/src/sim/loyalty.ts index 0066bea..e3d0f16 100644 --- a/src/sim/loyalty.ts +++ b/src/sim/loyalty.ts @@ -134,8 +134,11 @@ export function addStamp(l: LoyaltyState, cardId: string, type: StampType): Rewa }); 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; - card.stamps = current.tracks.map(() => 0); } return earned; } diff --git a/src/sim/transactions.ts b/src/sim/transactions.ts index bb31367..047d112 100644 --- a/src/sim/transactions.ts +++ b/src/sim/transactions.ts @@ -1,7 +1,7 @@ import { Catalog, getGame } from './catalog'; import { customerMinimumAsk, priceOf } from './economy'; import { Customer } from './customers'; -import { addToBackroom, ItemRef, takeFromSlot } from './inventory'; +import { addToBackroom, getSlot, ItemRef, takeFromSlot } from './inventory'; import { addStamp, currentProgram, issueCard, joinProbability, Reward, StampType } from './loyalty'; import { random } from './rng'; import { adjustReputation, GameState } from './state'; @@ -53,10 +53,10 @@ export function sell(state: GameState, catalog: Catalog, slotId: string, custome const item = takeFromSlot(state.inventory, slotId); let price = priceOf(state.prices, catalog, item); - const rewardUsed = takeReward(state, customer, (r) => - (r.kind === 'free_used_game' && item.condition !== 'new' && price <= r.maxValueCents) || - r.kind === 'discount_percent', - ); + 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); @@ -97,6 +97,11 @@ export function trade( 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; @@ -104,7 +109,7 @@ export function trade( let voucherGivenCents = 0; if (difference >= 0) { - const freeTrade = takeReward(state, customer, (r) => r.kind === 'free_trade'); + const freeTrade = difference > 0 ? takeReward(state, customer, (r) => r.kind === 'free_trade') : null; customerPaysCents = freeTrade ? 0 : difference; state.cash += customerPaysCents; state.day.revenueCents += customerPaysCents; diff --git a/tests/sim/catalog.test.ts b/tests/sim/catalog.test.ts index ef882a6..cd6dcb2 100644 --- a/tests/sim/catalog.test.ts +++ b/tests/sim/catalog.test.ts @@ -34,4 +34,8 @@ describe('catalog', () => { } 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'); + }); }); diff --git a/tests/sim/loyalty.test.ts b/tests/sim/loyalty.test.ts index 94a347e..6d72c05 100644 --- a/tests/sim/loyalty.test.ts +++ b/tests/sim/loyalty.test.ts @@ -126,4 +126,21 @@ describe('loyalty', () => { 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]); + }); }); diff --git a/tests/sim/transactions.test.ts b/tests/sim/transactions.test.ts index 5a20e2d..c2832a1 100644 --- a/tests/sim/transactions.test.ts +++ b/tests/sim/transactions.test.ts @@ -166,4 +166,35 @@ describe('requests', () => { recordSatisfiedRequest(s); expect(s.reputation).toBe(20.5); }); + +describe('review fixes', () => { + 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); + }); +}); }); From 3a8a110a2eca2e15ad67112a5e642a192cca0726 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:39:34 +0200 Subject: [PATCH 17/29] chore(sim): review nits --- src/sim/catalog.ts | 2 +- tests/sim/transactions.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sim/catalog.ts b/src/sim/catalog.ts index 8e2780d..297ceff 100644 --- a/src/sim/catalog.ts +++ b/src/sim/catalog.ts @@ -30,7 +30,7 @@ export function createCatalog(games: Game[]): Catalog { const byId = new Map(); for (const g of games) { if (byId.has(g.id)) throw new Error(`Duplicate game id: ${g.id}`); - if (g.launchPriceCents <= 0) throw new Error(`Invalid launch price: ${g.id}`); + if (!(g.launchPriceCents > 0)) throw new Error(`Invalid launch price: ${g.id}`); byId.set(g.id, g); } return { games, byId }; diff --git a/tests/sim/transactions.test.ts b/tests/sim/transactions.test.ts index c2832a1..f7dab32 100644 --- a/tests/sim/transactions.test.ts +++ b/tests/sim/transactions.test.ts @@ -166,8 +166,9 @@ describe('requests', () => { recordSatisfiedRequest(s); expect(s.reputation).toBe(20.5); }); +}); -describe('review fixes', () => { +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); @@ -197,4 +198,3 @@ describe('review fixes', () => { expect(s.day.trades).toBe(0); }); }); -}); From af10c3e8fd896cc18be53401ba94e18abf4fc30e Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:40:10 +0200 Subject: [PATCH 18/29] feat(sim): clock tick and events --- src/sim/clock.ts | 31 +++++++++++++++++++++++++++++++ tests/sim/clock.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/sim/clock.ts create mode 100644 tests/sim/clock.test.ts diff --git a/src/sim/clock.ts b/src/sim/clock.ts new file mode 100644 index 0000000..a48e52b --- /dev/null +++ b/src/sim/clock.ts @@ -0,0 +1,31 @@ +import { Catalog } from './catalog'; +import { Customer, customersPerHour, spawnCustomer } from './customers'; +import { random } from './rng'; +import { CLOSE_MINUTES, GameState, OPEN_MINUTES } from './state'; + +export type SimEvent = + | { type: 'shop_opened' } + | { type: 'customer_arrived'; customer: Customer } + | { type: 'shop_closed' }; + +export function isOpen(state: GameState): boolean { + return state.minutes >= OPEN_MINUTES && state.minutes < CLOSE_MINUTES; +} + +/** Advances the clock minute by minute, stopping at closing time. */ +export function tick(state: GameState, catalog: Catalog, deltaMinutes: number): SimEvent[] { + const events: SimEvent[] = []; + for (let i = 0; i < deltaMinutes && state.minutes < CLOSE_MINUTES; i++) { + state.minutes += 1; + if (state.minutes === OPEN_MINUTES) events.push({ type: 'shop_opened' }); + if (state.minutes > OPEN_MINUTES) { + state.customerAccumulator += (customersPerHour(state.reputation) / 60) * (0.5 + random(state)); + while (state.customerAccumulator >= 1) { + state.customerAccumulator -= 1; + events.push({ type: 'customer_arrived', customer: spawnCustomer(state, catalog) }); + } + } + if (state.minutes === CLOSE_MINUTES) events.push({ type: 'shop_closed' }); + } + return events; +} diff --git a/tests/sim/clock.test.ts b/tests/sim/clock.test.ts new file mode 100644 index 0000000..8c95419 --- /dev/null +++ b/tests/sim/clock.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { isOpen, tick } from '../../src/sim/clock'; +import { CLOSE_MINUTES, createNewGame, OPEN_MINUTES } from '../../src/sim/state'; +import { testCatalog } from './helpers'; + +const catalog = testCatalog(); + +describe('clock', () => { + it('opens the shop at 9:00 with no customers before', () => { + const s = createNewGame(1); + const events = tick(s, catalog, 60); + expect(s.minutes).toBe(OPEN_MINUTES); + expect(events).toEqual([{ type: 'shop_opened' }]); + expect(isOpen(s)).toBe(true); + }); + + it('brings customers during opening hours and closes at 19:00', () => { + const s = createNewGame(1); + s.reputation = 100; + const events = tick(s, catalog, 24 * 60); + expect(s.minutes).toBe(CLOSE_MINUTES); + expect(events.filter((e) => e.type === 'customer_arrived').length).toBeGreaterThan(10); + expect(events[events.length - 1]).toEqual({ type: 'shop_closed' }); + expect(isOpen(s)).toBe(false); + }); + + it('does nothing once closed', () => { + const s = createNewGame(1); + tick(s, catalog, 24 * 60); + expect(tick(s, catalog, 60)).toEqual([]); + expect(s.minutes).toBe(CLOSE_MINUTES); + }); +}); From d66832e4a1f5e51b1fda66f97971f60d2496b37a Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:40:27 +0200 Subject: [PATCH 19/29] feat(sim): end of day, sundays, rent and bankruptcy --- src/sim/day.ts | 53 +++++++++++++++++++++++++++++++ tests/sim/day.test.ts | 74 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/sim/day.ts create mode 100644 tests/sim/day.test.ts diff --git a/src/sim/day.ts b/src/sim/day.ts new file mode 100644 index 0000000..f6156bb --- /dev/null +++ b/src/sim/day.ts @@ -0,0 +1,53 @@ +import { addDays, GameDate, isLastDayOfMonth, isSunday } from './calendar'; +import { MONTHLY_RENT_CENTS } from './economy'; +import { receiveDeliveries } from './orders'; +import { CLOSE_MINUTES, DAY_START_MINUTES, DayStats, emptyDayStats, GameState, PendingOrder } from './state'; + +export interface DayReport { + date: GameDate; + stats: DayStats; + rentPaidCents: number; + skippedSunday: boolean; + newMonth: boolean; + bankrupt: boolean; + deliveries: PendingOrder[]; +} + +function closeMonth(state: GameState): number { + state.cash -= MONTHLY_RENT_CENTS; + state.negativeMonthEnds = state.cash < 0 ? state.negativeMonthEnds + 1 : 0; + if (state.negativeMonthEnds >= 2) state.bankrupt = true; + return MONTHLY_RENT_CENTS; +} + +export function endDay(state: GameState): DayReport { + if (state.minutes < CLOSE_MINUTES) throw new Error('Shop is still open'); + const endedDate = state.date; + const stats = state.day; + let rentPaidCents = 0; + + if (isLastDayOfMonth(state.date)) rentPaidCents += closeMonth(state); + state.date = addDays(state.date, 1); + + let skippedSunday = false; + if (isSunday(state.date)) { + skippedSunday = true; + if (isLastDayOfMonth(state.date)) rentPaidCents += closeMonth(state); + state.date = addDays(state.date, 1); + } + + state.minutes = DAY_START_MINUTES; + state.day = emptyDayStats(); + state.customerAccumulator = 0; + const deliveries = receiveDeliveries(state); + + return { + date: endedDate, + stats, + rentPaidCents, + skippedSunday, + newMonth: state.date.month !== endedDate.month, + bankrupt: state.bankrupt, + deliveries, + }; +} diff --git a/tests/sim/day.test.ts b/tests/sim/day.test.ts new file mode 100644 index 0000000..6ad8295 --- /dev/null +++ b/tests/sim/day.test.ts @@ -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); + }); +}); From 73e5ee30b69375a7739af504553eed68d9d8cc2d Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:40:46 +0200 Subject: [PATCH 20/29] feat(sim): save and load --- src/sim/save.ts | 38 ++++++++++++++++++++++++++++++++++++++ tests/sim/save.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/sim/save.ts create mode 100644 tests/sim/save.test.ts diff --git a/src/sim/save.ts b/src/sim/save.ts new file mode 100644 index 0000000..9ef9784 --- /dev/null +++ b/src/sim/save.ts @@ -0,0 +1,38 @@ +import { GameState } from './state'; + +export interface SaveStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export type SaveSlot = 'current' | 'monthly'; + +export const SAVE_KEYS: Record = { + current: 'consolgames.save', + monthly: 'consolgames.save.monthly', +}; + +export function serialize(state: GameState): string { + return JSON.stringify(state); +} + +export function deserialize(json: string): GameState { + const data = JSON.parse(json); + if (data.saveVersion !== 1) throw new Error(`Unsupported save version: ${data.saveVersion}`); + if (typeof data.cash !== 'number' || !data.date || !data.inventory || !data.loyalty) { + throw new Error('Corrupted save'); + } + return data as GameState; +} + +/** Always writes the current save; also writes the monthly one when asked. */ +export function saveGame(storage: SaveStorage, state: GameState, options: { monthly: boolean }): void { + const json = serialize(state); + storage.setItem(SAVE_KEYS.current, json); + if (options.monthly) storage.setItem(SAVE_KEYS.monthly, json); +} + +export function loadGame(storage: SaveStorage, slot: SaveSlot): GameState | null { + const json = storage.getItem(SAVE_KEYS[slot]); + return json ? deserialize(json) : null; +} diff --git a/tests/sim/save.test.ts b/tests/sim/save.test.ts new file mode 100644 index 0000000..0483016 --- /dev/null +++ b/tests/sim/save.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { deserialize, loadGame, saveGame, SaveStorage, serialize } from '../../src/sim/save'; +import { createNewGame } from '../../src/sim/state'; +import { issueCard } from '../../src/sim/loyalty'; + +function memoryStorage(): SaveStorage { + const data = new Map(); + return { getItem: (k) => data.get(k) ?? null, setItem: (k, v) => void data.set(k, v) }; +} + +describe('save', () => { + it('round-trips the full state', () => { + const s = createNewGame(7); + issueCard(s.loyalty); + s.prices['tetris|new'] = 2500; + expect(deserialize(serialize(s))).toEqual(s); + }); + + it('rejects unknown save versions', () => { + const s = { ...createNewGame(1), saveVersion: 99 }; + expect(() => deserialize(JSON.stringify(s))).toThrow('Unsupported save version: 99'); + }); + + it('rejects malformed saves', () => { + expect(() => deserialize('{"saveVersion":1}')).toThrow('Corrupted save'); + }); + + it('keeps the current and monthly saves apart', () => { + const storage = memoryStorage(); + const s = createNewGame(1); + saveGame(storage, s, { monthly: true }); + s.cash = 1; + saveGame(storage, s, { monthly: false }); + expect(loadGame(storage, 'current')!.cash).toBe(1); + expect(loadGame(storage, 'monthly')!.cash).not.toBe(1); + }); + + it('returns null when nothing is saved', () => { + expect(loadGame(memoryStorage(), 'current')).toBeNull(); + }); +}); From a677b9c61c7112d1ebd28d843f70d81a029ad60a Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:43:14 +0200 Subject: [PATCH 21/29] fix(sim): no arrivals at closing time, robust save import --- src/sim/clock.ts | 2 +- src/sim/save.ts | 19 ++++++++++++++++--- tests/sim/clock.test.ts | 20 ++++++++++++++++++++ tests/sim/save.test.ts | 15 ++++++++++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/sim/clock.ts b/src/sim/clock.ts index a48e52b..14268a2 100644 --- a/src/sim/clock.ts +++ b/src/sim/clock.ts @@ -18,7 +18,7 @@ export function tick(state: GameState, catalog: Catalog, deltaMinutes: number): for (let i = 0; i < deltaMinutes && state.minutes < CLOSE_MINUTES; i++) { state.minutes += 1; if (state.minutes === OPEN_MINUTES) events.push({ type: 'shop_opened' }); - if (state.minutes > OPEN_MINUTES) { + if (state.minutes > OPEN_MINUTES && state.minutes < CLOSE_MINUTES) { state.customerAccumulator += (customersPerHour(state.reputation) / 60) * (0.5 + random(state)); while (state.customerAccumulator >= 1) { state.customerAccumulator -= 1; diff --git a/src/sim/save.ts b/src/sim/save.ts index 9ef9784..159e395 100644 --- a/src/sim/save.ts +++ b/src/sim/save.ts @@ -16,12 +16,25 @@ export function serialize(state: GameState): string { return JSON.stringify(state); } +const OBJECT_FIELDS = ['date', 'inventory', 'loyalty', 'day', 'prices', 'pendingOrders'] as const; +const NUMBER_FIELDS = ['cash', 'minutes', 'reputation', 'rngState', 'nextCustomerId', 'customerAccumulator'] as const; + export function deserialize(json: string): GameState { - const data = JSON.parse(json); - if (data.saveVersion !== 1) throw new Error(`Unsupported save version: ${data.saveVersion}`); - if (typeof data.cash !== 'number' || !data.date || !data.inventory || !data.loyalty) { + let data: unknown; + try { + data = JSON.parse(json); + } catch { throw new Error('Corrupted save'); } + if (typeof data !== 'object' || data === null || Array.isArray(data)) throw new Error('Corrupted save'); + const record = data as Record; + if (record.saveVersion !== 1) throw new Error(`Unsupported save version: ${record.saveVersion}`); + for (const field of OBJECT_FIELDS) { + if (typeof record[field] !== 'object' || record[field] === null) throw new Error('Corrupted save'); + } + for (const field of NUMBER_FIELDS) { + if (typeof record[field] !== 'number') throw new Error('Corrupted save'); + } return data as GameState; } diff --git a/tests/sim/clock.test.ts b/tests/sim/clock.test.ts index 8c95419..536e9ec 100644 --- a/tests/sim/clock.test.ts +++ b/tests/sim/clock.test.ts @@ -24,6 +24,26 @@ describe('clock', () => { expect(isOpen(s)).toBe(false); }); + it('never brings customers on the closing tick', () => { + for (let seed = 1; seed <= 20; seed++) { + const s = createNewGame(seed); + s.reputation = 100; + const arrivalMinutes: number[] = []; + let opened = 0; + let closed = 0; + while (s.minutes < CLOSE_MINUTES) { + const events = tick(s, catalog, 1); + if (events.some((e) => e.type === 'customer_arrived')) arrivalMinutes.push(s.minutes); + opened += events.filter((e) => e.type === 'shop_opened').length; + closed += events.filter((e) => e.type === 'shop_closed').length; + } + expect(arrivalMinutes.length).toBeGreaterThan(0); + expect(arrivalMinutes).not.toContain(CLOSE_MINUTES); + expect(opened).toBe(1); + expect(closed).toBe(1); + } + }); + it('does nothing once closed', () => { const s = createNewGame(1); tick(s, catalog, 24 * 60); diff --git a/tests/sim/save.test.ts b/tests/sim/save.test.ts index 0483016..2b45ed2 100644 --- a/tests/sim/save.test.ts +++ b/tests/sim/save.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { deserialize, loadGame, saveGame, SaveStorage, serialize } from '../../src/sim/save'; import { createNewGame } from '../../src/sim/state'; import { issueCard } from '../../src/sim/loyalty'; +import { STARTING_CASH_CENTS } from '../../src/sim/economy'; function memoryStorage(): SaveStorage { const data = new Map(); @@ -25,6 +26,18 @@ describe('save', () => { expect(() => deserialize('{"saveVersion":1}')).toThrow('Corrupted save'); }); + it('rejects invalid JSON and non-object saves', () => { + expect(() => deserialize('{not json')).toThrow('Corrupted save'); + expect(() => deserialize('null')).toThrow('Corrupted save'); + expect(() => deserialize('[]')).toThrow('Corrupted save'); + expect(() => deserialize('42')).toThrow('Corrupted save'); + }); + + it('rejects saves with a missing field', () => { + const { day: _day, ...rest } = createNewGame(1); + expect(() => deserialize(JSON.stringify(rest))).toThrow('Corrupted save'); + }); + it('keeps the current and monthly saves apart', () => { const storage = memoryStorage(); const s = createNewGame(1); @@ -32,7 +45,7 @@ describe('save', () => { s.cash = 1; saveGame(storage, s, { monthly: false }); expect(loadGame(storage, 'current')!.cash).toBe(1); - expect(loadGame(storage, 'monthly')!.cash).not.toBe(1); + expect(loadGame(storage, 'monthly')!.cash).toBe(STARTING_CASH_CENTS); }); it('returns null when nothing is saved', () => { From 0de26d33f7896ca2f9639ce100b007781703d121 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:43:47 +0200 Subject: [PATCH 22/29] feat(sim): scripted autoplay and one-year stability test --- src/sim/autoplay.ts | 85 ++++++++++++++++++++++++++++++++++++++ tests/sim/autoplay.test.ts | 35 ++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/sim/autoplay.ts create mode 100644 tests/sim/autoplay.test.ts diff --git a/src/sim/autoplay.ts b/src/sim/autoplay.ts new file mode 100644 index 0000000..a0cc25b --- /dev/null +++ b/src/sim/autoplay.ts @@ -0,0 +1,85 @@ +import { availableGames, Catalog, getGame } from './catalog'; +import { tick } from './clock'; +import { Customer, chooseShelfItem } from './customers'; +import { DayReport, endDay } from './day'; +import { demandWeight } from './demand'; +import { customerMinimumAsk, MONTHLY_RENT_CENTS, wholesaleCost } from './economy'; +import { itemKey, parseItemKey, stockSlot, totalQty } from './inventory'; +import { placeOrder } from './orders'; +import { CLOSE_MINUTES, GameState } from './state'; +import { buyBack, recordLostRequest, recordSatisfiedRequest, sell, trade } from './transactions'; + +const ORDER_TOP_GAMES = 8; +const TARGET_STOCK = 3; + +/** Moves every backroom item onto a matching or empty slot. */ +export function restock(state: GameState): void { + for (const [key, qty] of Object.entries(state.inventory.backroom)) { + const item = parseItemKey(key); + let left = qty; + for (const slot of state.inventory.slots) { + if (left === 0) break; + if (slot.item && itemKey(slot.item) !== key) continue; + left -= stockSlot(state.inventory, slot.id, item, left); + } + } +} + +function serve(state: GameState, catalog: Catalog, customer: Customer): void { + switch (customer.intent) { + case 'buy': { + const slotId = chooseShelfItem(state, catalog, customer); + if (slotId) sell(state, catalog, slotId, customer); + return; + } + case 'request': { + const slotId = chooseShelfItem(state, catalog, customer); + if (slotId) { + sell(state, catalog, slotId, customer); + recordSatisfiedRequest(state); + } else { + recordLostRequest(state); + } + return; + } + case 'buy_back': { + if (!customer.offered) return; + const game = getGame(catalog, customer.offered.gameId); + const ask = customerMinimumAsk(game, customer.offered.condition, 'cash'); + if (state.cash - ask > MONTHLY_RENT_CENTS) buyBack(state, catalog, customer, ask); + return; + } + case 'trade': { + if (!customer.offered) return; + const slotId = chooseShelfItem(state, catalog, { ...customer, intent: 'buy' }); + if (!slotId) return; + const game = getGame(catalog, customer.offered.gameId); + trade(state, catalog, customer, slotId, customerMinimumAsk(game, customer.offered.condition, 'trade')); + return; + } + } +} + +function autoOrder(state: GameState, catalog: Catalog): void { + const top = availableGames(catalog, state.date) + .sort((a, b) => demandWeight(b, state.date) - demandWeight(a, state.date)) + .slice(0, ORDER_TOP_GAMES); + for (const game of top) { + const missing = TARGET_STOCK - totalQty(state.inventory, { gameId: game.id, condition: 'new' }); + if (missing <= 0) continue; + if (state.cash - wholesaleCost(game) * missing < MONTHLY_RENT_CENTS) continue; + placeOrder(state, catalog, game.id, missing); + } +} + +/** Plays a full day with a simple scripted strategy. */ +export function autoplayDay(state: GameState, catalog: Catalog): DayReport { + restock(state); + while (state.minutes < CLOSE_MINUTES) { + for (const event of tick(state, catalog, 60)) { + if (event.type === 'customer_arrived') serve(state, catalog, event.customer); + } + } + autoOrder(state, catalog); + return endDay(state); +} diff --git a/tests/sim/autoplay.test.ts b/tests/sim/autoplay.test.ts new file mode 100644 index 0000000..c176d96 --- /dev/null +++ b/tests/sim/autoplay.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { autoplayDay } from '../../src/sim/autoplay'; +import { createNewGame } from '../../src/sim/state'; +import { compareDates } from '../../src/sim/calendar'; +import { loadDefaultCatalog } from '../../src/sim/catalog'; +import { STARTING_CASH_CENTS } from '../../src/sim/economy'; + +describe('autoplay', () => { + it('plays one day end to end', () => { + const s = createNewGame(1); + const report = autoplayDay(s, loadDefaultCatalog()); + expect(report.date).toEqual({ year: 1990, month: 9, day: 28 }); + expect(s.date).toEqual({ year: 1990, month: 9, day: 29 }); + }); + + it('keeps the economy stable over one year', () => { + const catalog = loadDefaultCatalog(); + for (const seed of [1, 2, 3]) { + const s = createNewGame(seed); + let sales = 0; + let lost = 0; + while (compareDates(s.date, { year: 1991, month: 9, day: 28 }) < 0) { + const report = autoplayDay(s, catalog); + sales += report.stats.salesCount; + lost += report.stats.lostRequests; + expect(s.bankrupt).toBe(false); + } + expect(s.cash).toBeGreaterThan(0); + expect(s.cash).toBeLessThan(STARTING_CASH_CENTS * 100); + expect(sales).toBeGreaterThan(300); + expect(lost).toBeGreaterThan(0); + expect(s.reputation).toBeGreaterThan(0); + } + }); +}); From 495da04b3e646ec0d33d7aeeaf6e64b0fe6d14b1 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:43:58 +0200 Subject: [PATCH 23/29] feat(sim): public exports --- src/sim/index.ts | 16 ++++++++++++++++ tests/sim/smoke.test.ts | 7 ------- 2 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 src/sim/index.ts delete mode 100644 tests/sim/smoke.test.ts diff --git a/src/sim/index.ts b/src/sim/index.ts new file mode 100644 index 0000000..0a4385a --- /dev/null +++ b/src/sim/index.ts @@ -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'; diff --git a/tests/sim/smoke.test.ts b/tests/sim/smoke.test.ts deleted file mode 100644 index 04b41f4..0000000 --- a/tests/sim/smoke.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('smoke', () => { - it('runs', () => { - expect(1 + 1).toBe(2); - }); -}); From d978834d7e89be878b780658b4306fe215f17670 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:48:13 +0200 Subject: [PATCH 24/29] fix(sim): accumulate fractional ticks --- src/sim/clock.ts | 14 +++++++++++--- src/sim/day.ts | 1 + src/sim/save.ts | 2 +- src/sim/state.ts | 2 ++ tests/sim/clock.test.ts | 16 ++++++++++++++++ 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/sim/clock.ts b/src/sim/clock.ts index 14268a2..f0a914a 100644 --- a/src/sim/clock.ts +++ b/src/sim/clock.ts @@ -12,10 +12,15 @@ export function isOpen(state: GameState): boolean { return state.minutes >= OPEN_MINUTES && state.minutes < CLOSE_MINUTES; } -/** Advances the clock minute by minute, stopping at closing time. */ +/** 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[] = []; - for (let i = 0; i < deltaMinutes && state.minutes < CLOSE_MINUTES; i++) { + 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) { @@ -25,7 +30,10 @@ export function tick(state: GameState, catalog: Catalog, deltaMinutes: number): events.push({ type: 'customer_arrived', customer: spawnCustomer(state, catalog) }); } } - if (state.minutes === CLOSE_MINUTES) events.push({ type: 'shop_closed' }); + if (state.minutes === CLOSE_MINUTES) { + state.minuteAccumulator = 0; + events.push({ type: 'shop_closed' }); + } } return events; } diff --git a/src/sim/day.ts b/src/sim/day.ts index f6156bb..3b9b4e6 100644 --- a/src/sim/day.ts +++ b/src/sim/day.ts @@ -39,6 +39,7 @@ export function endDay(state: GameState): DayReport { state.minutes = DAY_START_MINUTES; state.day = emptyDayStats(); state.customerAccumulator = 0; + state.minuteAccumulator = 0; const deliveries = receiveDeliveries(state); return { diff --git a/src/sim/save.ts b/src/sim/save.ts index 159e395..b809530 100644 --- a/src/sim/save.ts +++ b/src/sim/save.ts @@ -17,7 +17,7 @@ export function serialize(state: GameState): string { } const OBJECT_FIELDS = ['date', 'inventory', 'loyalty', 'day', 'prices', 'pendingOrders'] as const; -const NUMBER_FIELDS = ['cash', 'minutes', 'reputation', 'rngState', 'nextCustomerId', 'customerAccumulator'] as const; +const NUMBER_FIELDS = ['cash', 'minutes', 'reputation', 'rngState', 'nextCustomerId', 'customerAccumulator', 'minuteAccumulator'] as const; export function deserialize(json: string): GameState { let data: unknown; diff --git a/src/sim/state.ts b/src/sim/state.ts index 03307b9..62fe6de 100644 --- a/src/sim/state.ts +++ b/src/sim/state.ts @@ -38,6 +38,7 @@ export interface GameState { rngState: number; nextCustomerId: number; customerAccumulator: number; + minuteAccumulator: number; } export function emptyDayStats(): DayStats { @@ -61,6 +62,7 @@ export function createNewGame(seed: number): GameState { rngState: seed, nextCustomerId: 1, customerAccumulator: 0, + minuteAccumulator: 0, }; } diff --git a/tests/sim/clock.test.ts b/tests/sim/clock.test.ts index 536e9ec..91237f0 100644 --- a/tests/sim/clock.test.ts +++ b/tests/sim/clock.test.ts @@ -44,6 +44,22 @@ describe('clock', () => { } }); + 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); From 736470eb788585dab7cd3ebe3b28a0bb6743b376 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:48:44 +0200 Subject: [PATCH 25/29] feat(sim): changeLoyaltyProgram applies reputation penalty --- src/sim/transactions.ts | 8 +++++++- tests/sim/transactions.test.ts | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/sim/transactions.ts b/src/sim/transactions.ts index 047d112..6a104c7 100644 --- a/src/sim/transactions.ts +++ b/src/sim/transactions.ts @@ -2,7 +2,7 @@ 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, StampType } from './loyalty'; +import { addStamp, currentProgram, issueCard, joinProbability, Reward, setProgram, StampTrack, StampType } from './loyalty'; import { random } from './rng'; import { adjustReputation, GameState } from './state'; @@ -128,6 +128,12 @@ export function 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); diff --git a/tests/sim/transactions.test.ts b/tests/sim/transactions.test.ts index f7dab32..41cba3d 100644 --- a/tests/sim/transactions.test.ts +++ b/tests/sim/transactions.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buyBack, recordLostRequest, recordSatisfiedRequest, sell, trade } from '../../src/sim/transactions'; +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'; @@ -198,3 +198,20 @@ describe('reward and cash edge cases', () => { 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); + }); +}); From b1d0c4fd16fc23faf31f306c2e4492c5cd650033 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:49:07 +0200 Subject: [PATCH 26/29] fix(sim): display whole franc amounts without conversion drift --- src/sim/money.ts | 3 ++- tests/sim/money.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/sim/money.ts b/src/sim/money.ts index b21abcb..2ae14f2 100644 --- a/src/sim/money.ts +++ b/src/sim/money.ts @@ -12,7 +12,8 @@ export function centsToFrancs(cents: number): number { export function formatMoney(cents: number, date: GameDate): string { const euro = isEuroEra(date); - const value = euro ? cents / 100 : centsToFrancs(cents); + // 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`; } diff --git a/tests/sim/money.test.ts b/tests/sim/money.test.ts index dd2c165..be06f7a 100644 --- a/tests/sim/money.test.ts +++ b/tests/sim/money.test.ts @@ -16,6 +16,14 @@ describe('money', () => { 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 €'); }); From d78e6ebd26ce28ff049cd046a9fc3b233f6bf049 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:49:23 +0200 Subject: [PATCH 27/29] feat(sim): move items from shelf back to backroom --- src/sim/inventory.ts | 12 ++++++++++++ tests/sim/inventory.test.ts | 25 ++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/sim/inventory.ts b/src/sim/inventory.ts index c84d0ec..35b175d 100644 --- a/src/sim/inventory.ts +++ b/src/sim/inventory.ts @@ -64,6 +64,18 @@ export function stockSlot(inv: Inventory, slotId: string, item: ItemRef, qty: nu 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`); diff --git a/tests/sim/inventory.test.ts b/tests/sim/inventory.test.ts index 2107023..ab6845a 100644 --- a/tests/sim/inventory.test.ts +++ b/tests/sim/inventory.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { addToBackroom, backroomQty, createInventory, getSlot, itemKey, parseItemKey, - shelfItems, SLOT_CAPACITY, stockSlot, takeFromSlot, totalQty, + shelfItems, SLOT_CAPACITY, stockSlot, takeFromSlot, totalQty, unstockSlot, } from '../../src/sim/inventory'; const tetrisNew = { gameId: 'tetris', condition: 'new' as const }; @@ -60,6 +60,29 @@ describe('inventory', () => { 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'); }); From a7d3be7e1ba7f61c5a1dccafaa952dbad3885d97 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:49:46 +0200 Subject: [PATCH 28/29] fix(sim): validate bankruptcy fields on load --- src/sim/save.ts | 3 ++- tests/sim/save.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/sim/save.ts b/src/sim/save.ts index b809530..fa42ec0 100644 --- a/src/sim/save.ts +++ b/src/sim/save.ts @@ -17,7 +17,7 @@ export function serialize(state: GameState): string { } const OBJECT_FIELDS = ['date', 'inventory', 'loyalty', 'day', 'prices', 'pendingOrders'] as const; -const NUMBER_FIELDS = ['cash', 'minutes', 'reputation', 'rngState', 'nextCustomerId', 'customerAccumulator', 'minuteAccumulator'] as const; +const NUMBER_FIELDS = ['cash', 'minutes', 'reputation', 'rngState', 'nextCustomerId', 'customerAccumulator', 'minuteAccumulator', 'negativeMonthEnds'] as const; export function deserialize(json: string): GameState { let data: unknown; @@ -35,6 +35,7 @@ export function deserialize(json: string): GameState { 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; } diff --git a/tests/sim/save.test.ts b/tests/sim/save.test.ts index 2b45ed2..e659ec0 100644 --- a/tests/sim/save.test.ts +++ b/tests/sim/save.test.ts @@ -38,6 +38,14 @@ describe('save', () => { 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); From 70599d59b157e1ca654e3e57ee827b8c0dbca694 Mon Sep 17 00:00:00 2001 From: Victor Bodinaud Date: Thu, 8 Oct 2026 12:50:06 +0200 Subject: [PATCH 29/29] fix(sim): autoplay avoids unaffordable trades --- src/sim/autoplay.ts | 16 +++++++++++----- tests/sim/autoplay.test.ts | 17 ++++++++++++++++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/sim/autoplay.ts b/src/sim/autoplay.ts index a0cc25b..6597fc5 100644 --- a/src/sim/autoplay.ts +++ b/src/sim/autoplay.ts @@ -3,8 +3,8 @@ import { tick } from './clock'; import { Customer, chooseShelfItem } from './customers'; import { DayReport, endDay } from './day'; import { demandWeight } from './demand'; -import { customerMinimumAsk, MONTHLY_RENT_CENTS, wholesaleCost } from './economy'; -import { itemKey, parseItemKey, stockSlot, totalQty } from './inventory'; +import { 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'; @@ -25,7 +25,7 @@ export function restock(state: GameState): void { } } -function serve(state: GameState, catalog: Catalog, customer: Customer): void { +export function serveCustomer(state: GameState, catalog: Catalog, customer: Customer): void { switch (customer.intent) { case 'buy': { const slotId = chooseShelfItem(state, catalog, customer); @@ -54,7 +54,13 @@ function serve(state: GameState, catalog: Catalog, customer: Customer): void { const slotId = chooseShelfItem(state, catalog, { ...customer, intent: 'buy' }); if (!slotId) return; const game = getGame(catalog, customer.offered.gameId); - trade(state, catalog, customer, slotId, customerMinimumAsk(game, customer.offered.condition, 'trade')); + 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; } } @@ -77,7 +83,7 @@ export function autoplayDay(state: GameState, catalog: Catalog): DayReport { restock(state); while (state.minutes < CLOSE_MINUTES) { for (const event of tick(state, catalog, 60)) { - if (event.type === 'customer_arrived') serve(state, catalog, event.customer); + if (event.type === 'customer_arrived') serveCustomer(state, catalog, event.customer); } } autoOrder(state, catalog); diff --git a/tests/sim/autoplay.test.ts b/tests/sim/autoplay.test.ts index c176d96..58ac968 100644 --- a/tests/sim/autoplay.test.ts +++ b/tests/sim/autoplay.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { autoplayDay } from '../../src/sim/autoplay'; +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', () => { @@ -32,4 +34,17 @@ describe('autoplay', () => { 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); + }); });