feat: First iteration, multi themes, I18N

This commit is contained in:
gauvainboiche
2026-10-08 11:58:18 +02:00
parent 9240fa3c3c
commit aa9721ac2a
53 changed files with 3363 additions and 1 deletions
+70
View File
@@ -0,0 +1,70 @@
// Guards the content packs: every theme and language must be complete, so a
// missing translation is caught by `npm test` instead of showing a raw key.
import assert from 'node:assert/strict';
import { readdirSync } from 'node:fs';
import { test } from 'node:test';
import { flatKeys, loadContext, PUBLIC_DIR, readJson } from './helpers.js';
const ctx = loadContext();
const codes = (dir) =>
readdirSync(PUBLIC_DIR + dir)
.filter((f) => f.endsWith('.json'))
.map((f) => f.slice(0, -5));
const themes = readdirSync(PUBLIC_DIR + 'themes', { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name);
test('core locales define the same keys as English', () => {
const reference = flatKeys(readJson('locales/en.json')).sort();
for (const code of codes('locales')) {
assert.deepEqual(flatKeys(readJson(`locales/${code}.json`)).sort(), reference, `locales/${code}.json`);
}
});
test('every effect type has a core text', () => {
const keys = new Set(flatKeys(readJson('locales/en.json')));
for (const u of ctx.upgrades) assert.ok(keys.has(`effect.${u.effect.type}`), u.effect.type);
});
test('there is at least one theme, each with a manifest, a stylesheet and English texts', () => {
assert.ok(themes.length >= 1);
for (const id of themes) {
assert.ok(readdirSync(`${PUBLIC_DIR}themes/${id}`).includes('theme.css'), `${id}/theme.css`);
assert.ok(codes(`themes/${id}/locales`).includes('en'), `${id} needs an en locale`);
assert.ok(readJson(`themes/${id}/theme.json`));
}
});
test('every theme names every resource, generator and upgrade, in every language', () => {
const required = [
'theme.name',
'theme.title',
'theme.res.units',
'theme.res.unit',
'theme.res.funds',
'theme.res.material',
'theme.fmt.funds',
'theme.action.make',
'theme.action.buyMaterial',
...ctx.generators.flatMap((g) => [`theme.gen.${g.id}.name`, `theme.gen.${g.id}.desc`]),
...ctx.upgrades.map((u) => `theme.upg.${u.id}`),
];
for (const id of themes) {
const reference = flatKeys(readJson(`themes/${id}/locales/en.json`)).sort();
assert.deepEqual(reference.filter((k) => required.includes(k)).sort(), [...required].sort(), `${id}/en`);
for (const code of codes(`themes/${id}/locales`)) {
const keys = flatKeys(readJson(`themes/${id}/locales/${code}.json`)).sort();
assert.deepEqual(keys, reference, `${id}/${code} differs from ${id}/en`);
}
}
});
test('every theme is translated in every core language', () => {
for (const id of themes) {
for (const code of codes('locales')) {
assert.ok(codes(`themes/${id}/locales`).includes(code), `${id} has no "${code}" texts`);
}
}
});
+195
View File
@@ -0,0 +1,195 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
advance,
buyGenerator,
buyMarketing,
buyMaterial,
buyUpgrade,
click,
derive,
generatorQuote,
setPrice,
simulateOffline,
upgradeVisible,
} from '../public/js/engine/engine.js';
import { bulkCost, demandPerSecond, maxAffordable } from '../public/js/engine/formulas.js';
import { createState, sanitizeState, serializeState } from '../public/js/engine/state.js';
import { loadContext } from './helpers.js';
const ctx = loadContext();
const fresh = () => createState(ctx, 1_000_000);
test('bulkCost matches buying one at a time', () => {
let owned = 3;
let total = 0;
for (let i = 0; i < 10; i++) total += Math.ceil(10 * 1.15 ** owned++);
assert.ok(Math.abs(bulkCost(10, 1.15, 3, 10) - total) <= 10);
});
test('maxAffordable never overspends and is maximal', () => {
for (const budget of [0, 4, 5, 100, 12345, 9e9]) {
const n = maxAffordable(5, 1.15, 7, budget);
assert.ok(bulkCost(5, 1.15, 7, n) <= budget);
assert.ok(bulkCost(5, 1.15, 7, n + 1) > budget);
}
});
test('demand is flat at/below the fair price and drops above it', () => {
const { market } = ctx.rules;
const atFair = demandPerSecond(market, market.fairPrice, 1);
assert.equal(demandPerSecond(market, market.fairPrice / 5, 1), atFair);
assert.ok(demandPerSecond(market, market.fairPrice * 2, 1) < atFair / 3);
});
test('click makes a unit from material', () => {
const s = fresh();
assert.ok(click(s, ctx));
assert.equal(s.units, 1);
assert.equal(s.material, ctx.rules.start.material - 1);
assert.equal(s.totalProduced, 1);
});
test('click does nothing without material', () => {
const s = fresh();
s.material = 0;
assert.equal(click(s, ctx), false);
assert.equal(s.units, 0);
});
test('selling turns stock into funds at the set price', () => {
const s = fresh();
s.units = 100;
setPrice(s, ctx, 0.25);
const { sold, revenue } = advance(s, ctx, 1);
assert.ok(sold > 0);
assert.ok(Math.abs(revenue - sold * 0.25) < 1e-9);
assert.ok(Math.abs(s.funds - revenue) < 1e-9);
});
test('generators produce, consuming material', () => {
const s = fresh();
s.funds = 1000;
assert.ok(buyGenerator(s, ctx, 'gen1', 3));
const before = s.material;
advance(s, ctx, 10);
assert.ok(s.totalProduced > 0);
assert.ok(s.material < before);
});
test('production stops when material runs out', () => {
const s = fresh();
s.generators.gen1 = 10;
s.material = 5;
advance(s, ctx, 60);
assert.ok(s.totalProduced <= 5 + 1e-9);
assert.ok(s.material >= 0);
});
test('buying is refused when funds are short, and charges when it works', () => {
const s = fresh();
assert.equal(buyGenerator(s, ctx, 'gen1', 1), false);
assert.equal(buyMaterial(s, ctx), false);
assert.equal(buyMarketing(s, ctx), false);
s.funds = 100;
const quote = generatorQuote(s, ctx, 'gen1', 5);
assert.ok(buyGenerator(s, ctx, 'gen1', 5));
assert.equal(s.generators.gen1, 5);
assert.equal(s.funds, 100 - quote.cost);
});
test('"max" buys as many as affordable', () => {
const s = fresh();
s.funds = 1000;
buyGenerator(s, ctx, 'gen1', 'max');
assert.ok(s.generators.gen1 > 10);
assert.ok(s.funds >= 0);
});
test('upgrades need their unlock condition and requirement', () => {
const s = fresh();
s.funds = 1e9;
assert.equal(buyUpgrade(s, ctx, 'click_1'), false); // not unlocked yet
s.totalProduced = 25;
assert.ok(buyUpgrade(s, ctx, 'click_1'));
assert.equal(buyUpgrade(s, ctx, 'click_1'), false); // already owned
s.totalProduced = 1e6;
assert.equal(upgradeVisible(s, ctx.upgradesById.get('mat_eff_2')), false); // requires mat_eff_1
buyUpgrade(s, ctx, 'mat_eff_1');
assert.ok(upgradeVisible(s, ctx.upgradesById.get('mat_eff_2')));
});
test('upgrade effects reach the derived values', () => {
const s = fresh();
s.upgrades = ['click_1', 'demand_1', 'mat_eff_1', 'auto_buy'];
const d = derive(s, ctx);
assert.equal(d.clickYield, 2);
assert.equal(d.demandMultiplier, 1.5);
assert.equal(d.materialPerUnit, 1 / 1.25);
assert.equal(d.autoBuyMaterial, true);
});
test('auto-buy restocks material when funds allow', () => {
const s = fresh();
s.upgrades = ['auto_buy'];
s.material = 10;
s.funds = 100;
advance(s, ctx, 1);
assert.ok(s.material > 50);
});
test('offline progress is capped, discounted, and ignored when short', () => {
const s = fresh();
s.generators.gen1 = 5;
s.material = 1e6;
assert.equal(simulateOffline(s, ctx, 3), null);
const report = simulateOffline(s, ctx, 10 * 24 * 3600);
assert.equal(report.seconds, ctx.rules.offline.maxSeconds);
assert.ok(report.produced > 0);
});
test('sanitizeState rejects junk and clamps values', () => {
assert.equal(sanitizeState('nope', ctx), null);
assert.equal(sanitizeState([], ctx), null);
const s = sanitizeState(
{
units: -5,
funds: 'lots',
price: 9999,
marketing: 1e9,
generators: { gen1: 3.7, ghost: 5, gen2: -2 },
upgrades: ['click_1', 'click_1', 'nonsense'],
savedAt: Number.POSITIVE_INFINITY,
},
ctx,
5_000,
);
assert.equal(s.units, 0);
assert.equal(s.funds, 0);
assert.equal(s.price, ctx.rules.market.maxPrice);
assert.ok(s.marketing <= 300);
assert.deepEqual(s.generators, { gen1: 3, gen2: 0, gen3: 0, gen4: 0, gen5: 0, gen6: 0 });
assert.deepEqual(s.upgrades, ['click_1']);
assert.equal(s.savedAt, 5_000);
});
test('state survives a serialize / sanitize round trip', () => {
const s = fresh();
s.funds = 123.456;
s.generators.gen2 = 4;
s.upgrades = ['click_1', 'g1_a'];
s.marketing = 3;
const back = sanitizeState(JSON.parse(serializeState(s)), ctx);
assert.equal(back.funds, 123.456);
assert.deepEqual(back.generators, s.generators);
assert.deepEqual(back.upgrades, s.upgrades);
assert.equal(back.marketing, 3);
});
test('the full save of a late game state is small enough for cookies', () => {
const s = fresh();
s.funds = 123456789.123456;
for (const g of ctx.generators) s.generators[g.id] = 2500;
s.upgrades = ctx.upgrades.map((u) => u.id);
assert.ok(serializeState(s).length < 1500);
});
+23
View File
@@ -0,0 +1,23 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { buildContext } from '../public/js/engine/context.js';
export const PUBLIC_DIR = fileURLToPath(new URL('../public/', import.meta.url));
export const readJson = (relativePath) => JSON.parse(readFileSync(PUBLIC_DIR + relativePath, 'utf8'));
export function loadContext() {
return buildContext({
rules: readJson('data/rules.json'),
generators: readJson('data/generators.json'),
upgrades: readJson('data/upgrades.json'),
});
}
/** Flatten nested locale JSON to dotted keys, like services/i18n.js does. */
export function flatKeys(object, prefix = '') {
return Object.entries(object).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
return value && typeof value === 'object' ? flatKeys(value, path) : [path];
});
}
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createState } from '../public/js/engine/state.js';
import { buildExport, ImportError, parseImport } from '../public/js/services/saveio.js';
import { decode, encode } from '../public/js/services/storage.js';
import { loadContext } from './helpers.js';
const ctx = loadContext();
const themes = ['paperclip', 'witch'];
test('export then import restores the save and its theme', () => {
const state = createState(ctx);
state.funds = 42;
state.generators.gen1 = 7;
const text = buildExport('witch', state);
const imported = parseImport(text, ctx, themes);
assert.equal(imported.themeId, 'witch');
assert.equal(imported.state.funds, 42);
assert.equal(imported.state.generators.gen1, 7);
});
test('import also accepts text with a BOM and surrounding whitespace (.txt files)', () => {
const text = ` \n${buildExport('paperclip', createState(ctx))}\n\n`;
assert.equal(parseImport(text, ctx, themes).themeId, 'paperclip');
});
const failure = (text) => {
try {
parseImport(text, ctx, themes);
} catch (error) {
assert.ok(error instanceof ImportError);
return error.code;
}
assert.fail('should have thrown');
};
test('import rejects bad input with a specific code', () => {
assert.equal(failure(''), 'empty');
assert.equal(failure('not json'), 'invalid');
assert.equal(failure('{"hello":1}'), 'wrong_game');
const unknown = JSON.parse(buildExport('paperclip', createState(ctx)));
unknown.theme = 'dragons';
assert.equal(failure(JSON.stringify(unknown)), 'unknown_theme');
const broken = JSON.parse(buildExport('paperclip', createState(ctx)));
broken.state = 'x';
assert.equal(failure(JSON.stringify(broken)), 'invalid');
});
test('cookie encoding is cookie-safe and lossless', () => {
const text = JSON.stringify({ accents: 'éàü — ✓', long: 'x'.repeat(5000) });
const encoded = encode(text);
assert.match(encoded, /^[A-Za-z0-9_-]*$/);
assert.equal(decode(encoded), text);
});
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import { test } from 'node:test';
import { handleApi } from '../server/api.js';
import { resolveSafe } from '../server/static.js';
import { PUBLIC_DIR } from './helpers.js';
test('resolveSafe stays inside the public directory', () => {
const root = path.resolve(PUBLIC_DIR);
assert.equal(resolveSafe(root, '/js/main.js'), path.join(root, 'js', 'main.js'));
for (const evil of ['/../package.json', '/..%2F..%2Fetc/passwd', '/a/../../x', '/%00', '/%E0%A4%A']) {
const result = resolveSafe(root, evil);
assert.ok(result === null || result.startsWith(root + path.sep), evil);
}
assert.equal(resolveSafe(root, '/%00'), null);
assert.equal(resolveSafe(root, '/%E0%A4%A'), null);
});
test('/api/themes lists installed themes with their languages', async () => {
const themes = await handleApi(PUBLIC_DIR, '/api/themes');
const ids = themes.map((t) => t.id);
assert.ok(ids.includes('paperclip') && ids.includes('witch') && ids.includes('alien'));
assert.deepEqual(themes.find((t) => t.id === 'paperclip').locales, ['en', 'fr']);
});
test('/api/locales lists languages with their own names', async () => {
const locales = await handleApi(PUBLIC_DIR, '/api/locales');
assert.deepEqual(locales.find((l) => l.code === 'fr'), { code: 'fr', name: 'Français' });
});
test('unknown API routes are not handled', async () => {
assert.equal(await handleApi(PUBLIC_DIR, '/api/nope'), undefined);
});