feat: First iteration, multi themes, I18N
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user