Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 80 additions & 11 deletions docs/game/lang.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,90 @@ for its words (AI Hub Languages v1).

`riftlightLines()` (`lang/index.ts`) is the game's one `LineList`: every line a player reads under
an id, with notes, widths, the proper names and the translator's brief (`lang/brief.ts`). It
gathers the areas' lines (`lang/ui.ts` and its neighbours: one `LineArea` per part of the game).
The page's `game-lines` block is this list (`npm run build:single` writes it).
gathers the areas' lines: one `LineArea` per part of the game, each in its own file (`ui.ts`,
`shell.ts`, `panels.ts`, `town.ts`, `showcase.ts`, `items.ts`, `mods.ts`, `skills.ts`, `tree.ts`,
`monsters.ts`, `levels.ts`). The page's `game-lines` block is this list (`npm run build:single`
writes it); `npm run lang -- lines` counts it by area.

The game shows a line with `t(id, values)` from `lang/index.ts`; `pageLanguage()` picks the page's
language at setup, says it on the page's `<html lang>`, and `installLanguage()` sets it (and how
text is drawn: `setTextLocale`).

### Content brings its lines

An area built from a registry is a function (`ITEMS()`, `MONSTERS()`, `TREE()`, ...): every base,
affix, unique, skill, support, stat, monster part, boss, mechanic and tree node gives a line under
an id made from its own (`lineKey('item.base', base.id)`), so content added as data brings its
lines along. The registries keep their English (it *is* the source line); code shows it through the
area's helpers, never the registry's text:

| Area | Helpers |
| --- | --- |
| items (`loot/names.ts`) | `itemName`, `itemLabel`, `baseName`, `affixName`, `uniqueName`, `currencyName`, `rarityName`, `damageName`, `gemName`, `reasonText`, `lootFilterOptions` |
| mods (`lang/mods.ts`, `lang/modLines.ts`) | `modText` (core's `describeMod`, loot's `describeItemMods` and the tree's text all use it), `statName`, `tagWord`, `scopeName`, `conditionName`, `modList` |
| skills (`lang/skills.ts`) | `skillName`, `skillDescription`, `supportName`, `ailmentName`, `chargeName`, `curseName` |
| tree (`tree/text.ts`) | `nodeName`, `nodeFlavour`, `nodeLines`, `kindName`, `regionName` |
| monsters (`lang/monsters.ts`, `wire/monsters.ts`) | `monsterName(genome)`, `bossNameShown`, `partName`, `planName`, `eliteName` |
| levels (`lang/levels.ts`) | `levelName(spec)`, `mechanicName`, `mechanicText`, `themeName` |
| shell (`game/progress.ts`, `game/controls.ts`) | `levelTitle(spec)`, `formatTime`, `controlLabel` |

**Mind the import graph.** `lang/index` imports every area and the areas import the registries, so
nothing low in the graph (a registry, `core/mods`, `tree/data/`) may import `lang/index`: a module
read before it exists crashes the load. Mod text lives in `lang/modLines.ts` for that reason (it
never imports the line list), and areas stay function declarations.

### Rules for a line

- **One id per line**, lowercase words joined by dots, saying where it shows (`round.go`,
`menu.title.new_run`). Never reuse an id for other words; keep an id once published.
- **Whole sentences with named blanks**: `'{name} reached level {level}'`, never
`name + ' REACHED LEVEL ' + level`. A number that counts something gets plural forms picked by
`{count}`: `{ one: '{count} kill', other: '{count} kills' }`.
- **Normal case.** The font draws capitals; lines are written as a sentence is (`New run`).
- **Names from parts** get a joining line (`'{prefix} {base} {suffix}'`), each part its own line.
- **Controls** are tags, `{key:dodge}`, never a key written into the sentence.
`{count}`: `{ one: '{count} kill', other: '{count} kills' }`. Numbers go in as blanks and are
formatted for the language (`lang().formatNumber`).
- **Normal case.** The font draws capitals; lines are written as a sentence is (`New run`). No
`toUpperCase()` on shown text (it breaks Turkish).
- **Names from parts** get a joining line, each part its own line: an item's
`'{prefix} {base} {suffix}'`, a monster's `'{theme} {body}'`, a rift's `'Rift {depth}: {mechanics}'`,
so a translation can reorder them.
- **Controls** are tags, `{key:dodge}`, never a key written into the sentence. `CONTROLS`
(`game/controls.ts`) maps each action to its key, pad button and touch button; the shell sets
`lang().controlLabel` to name them on the device in use (`controlDevice()`).
- **Notes** say who speaks, to whom, where it shows; **max** the width of a tight spot (a button).
- **Not lines**: debug text, numbers on their own, what players type, ids. Developer-only screens
(the dev menu, tuning) use ids starting `dev.`: never translated.
- **Saves keep ids and values, never finished sentences**, so a save shows in today's language.
- **Not lines**: debug text, numbers on their own, what players type, ids, a generated boss's
invented name. Developer-only screens (the dev menu, the Loot Lab) use ids starting `dev.`: never
translated. The pause menu's Tuning is the players' (difficulty presets): `menu.tuning.*`.
- **Saves keep ids and values, never finished sentences**, so a save shows in today's language: a
rare item keeps its two `words`, a speech bubble its line id, the death recap what rebuilds a
killer's name (a genome, a boss's id). Where a save must keep a name (the bestiary), it keeps the
English (`sourceLang()`), so a run ends on the same fingerprint in any language. An old save
holding English shows it as it is.
- **Proper names** (people, places, products) are in `NAMES` (`lang/brief.ts`).

## Showing the words

- **Choosing the language**: the hub's `?lang=`, then the player's choice, the browser's, English.
The title's **Language** entry lists the languages the page has, each in its own name, and
restarts in the one chosen; it shows only when the address has no `?lang=` (then the hub's menu
is in charge) and the page has more than English (a development build has no translations). With
`?lang=` the title also hides Engine World and the Asset Gallery (developer pages, not translated).
- **Room that grows**: text is measured (`textWidthPx`, `ui.measure`) and wrapped (`wrapText`, the
kit's `wrap`), never counted in characters; boxes, columns and buttons grow to fit. A cut
(`clip`) is the last resort. The 3×5 mini font draws only what it covers (`miniCovers`); other
text falls back to the normal font, one row higher.
- **Other writing systems** go to the device's font (docs/engine/lang.md): the HUD does it itself;
the tree view and the item windows draw their canvases at `surfaceScale` with device text.

## Checking

- **Tests per area** draw their screens under `en-XA` and fail on any word that isn't a line
(`ui/panels.test.ts`, `showcase/words.test.ts`, `tree/tree.test.ts`, `lang/levels.headless.test.ts`,
`ui/items/items.test.ts`); `lang/lines.test.ts` keeps every id, form and width valid and every
`t('literal')` in the code a line.
- **`npm run lang -- audit [--lang en-XA]`** (after `npm run build`) plays the built game under a
test language and lists every string drawn that isn't a line (`.scratch/lang/audit.json`, a
picture per screen). The test, `strays()`, is `scripts/lang-strays.mjs`, shared with the tests
and the hub package's browser check: under `en-XA` a word with a plain vowel is English (a line's
vowels are all accented, wrapped rows included), elsewhere any word of Latin letters.

## Translations and the two builds

Expand All @@ -38,5 +101,11 @@ changes. Translations are a separate step, at a release: kept as data in
was made from, so a line whose English changed since is **stale**: left out of the page (the player
sees the English, never an old meaning) until the next translation pass.

**The hand-over build** (`build:single -- --translations`) adds the page's `game-translations`
block: every stored language with its current lines.
- `npm run lang -- status` shows each language's current, stale and missing lines;
- `npm run lang -- todo <code>` writes what a translator needs (missing and stale lines with their
notes, widths, the names, the brief, the language's plural forms);
- `npm run lang -- apply <code> <file> --by <who>` files a translation after the checks (tags kept
exactly, plural forms the language needs, within `max`); `npm run lang -- check` re-checks all.

**The hand-over build** (`build:single -- --translations`, which `npm run hub` makes: docs/game/hub.md)
adds the page's `game-translations` block: every stored language with its current lines.
4 changes: 3 additions & 1 deletion docs/game/loot.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ U vendor, Alt filter).
Cinder (magic → rare), Rift Ember (reroll rare), Starfall Orb (add an affix), Hollow Orb
(remove one), Cleansing Salt (back to normal), Sunsoul Orb (reroll values), Abyssal Eye
(corrupt: nothing / an implicit / an affix up a tier / remade as a rare; no more crafting).
- New stats only need a human name in `loot/stats.ts` (`STAT_NAMES`, `PERCENT_STATS`).
- New stats only need a name in `STAT_NAMES` (lang/modLines.ts, the game's one table: items,
the tree and the character sheet read mods through `modText`) and, when a flat value is a
fraction, `PERCENT_STATS` (core/stats.ts).

**Rarity math** (`loot/generate.ts`, numbers in one place):

Expand Down
13 changes: 9 additions & 4 deletions docs/game/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,22 @@ Everything is data; the generator queries it by tags, so adding an entry is the
become the node id `n:<id>`. Keep it worth 25–45 budget points (`modsBudget`); the validator
flags anything over 60. Each notable is placed once per tree; more notables in a theme means
more instances of that cluster.
- **A keystone** (`data/keystones.ts`): mods plus flags, hand-written `lines` for the tooltip,
and a `region`. Each region gets its keystones spread along its rim. Document any new flag
- **A keystone** (`data/keystones.ts`): mods plus flags, hand-written `lines` for the tooltip
(a sentence, a sentence with its numbers in named blanks, or a mod: `NodeLine`), and a `region`. Each region gets its keystones spread along its rim. Document any new flag
in `KEYSTONE_FLAGS`.
- **A mastery** (`data/masteries.ts`): tags and three options. Shapes with a mastery slot
(`wheel1`, `wheel2`) take the best-matching mastery not yet used in that region.
- **A shape** (`data/shapes.ts`): slots in local units (+y points outwards, entry at -y, about
±110 across, ~55 between neighbours) and links. Tag it `n0`–`n3` by notable count.
- **A region** (`data/regions.ts`): an angle, colours, theme weights and a small-node pool. The
circle is split evenly between regions, so rebalance the angles.
- **A stat:** give it a tooltip name in `STAT_NAMES` and, if small nodes may roll it, a price
in `MOD_COSTS` (value per budget point).
- **A stat:** give it a name in `STAT_NAMES` (lang/modLines.ts: every mod in the game reads
through it) and a short one in `SMALL_NAMES` (lang/tree.ts) and, if small nodes may roll it, a
price in `MOD_COSTS` (value per budget point).
- **Words:** names, flavour and rules are lines (lang/tree.ts builds them from the registries,
docs/game/lang.md); a node keeps its English in `name`/`flavour` and where its lines are in
`text`, and the view shows them through tree/text.ts (`nodeName`, `nodeFlavour`, `nodeLines`,
`smallName`). Search matches the words as the player reads them.
- **A hand-placed node** (`data/overrides.ts`): `{ id, x, y, name, kind, region, mods, link }`
adds a node; an override with an existing id patches it (move it, change its mods, `link` /
`unlink`). Overrides are applied last and always win.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
"monster": "tsx scripts/riftlight/monster.ts",
"playtest": "tsx scripts/riftlight/playtest.ts",
"replay": "tsx scripts/replay.ts",
"balance": "tsx scripts/riftlight/balance.ts",
"inspect": "tsx scripts/inspect.ts",
"balance": "tsx --import ./scripts/trace/register.ts scripts/riftlight/balance.ts",
"inspect": "tsx --import ./scripts/trace/register.ts scripts/inspect.ts",
"lang": "tsx scripts/lang.ts",
"model": "tsx scripts/model.ts",
"tex": "tsx scripts/tex.ts",
Expand Down
6 changes: 3 additions & 3 deletions scripts/e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1222,7 +1222,7 @@ async function runTools(browserExe) {
const out = new URL('balance/', OUT);
await rm(out, { recursive: true, force: true });
const t0 = Date.now();
const ran = await run('npx', ['tsx', 'scripts/riftlight/balance.ts', '--depths', '1,13', '--max', '13', '--samples', '2', '--candidates', '2', '--out', '.scratch/e2e/balance', '--json']);
const ran = await run('npm', ['run', '--silent', 'balance', '--', '--depths', '1,13', '--max', '13', '--samples', '2', '--candidates', '2', '--out', '.scratch/e2e/balance', '--json']);
check(ran.code === 0, `balance exits 0 in ${((Date.now() - t0) / 1000).toFixed(1)} s${ran.code ? ':\n ' + tail(ran.out) : ''}`);
const report = JSON.parse(await readFile(new URL('balance.json', out), 'utf8'));
const rows = report.rows;
Expand All @@ -1243,7 +1243,7 @@ async function runTools(browserExe) {
try {
const out = new URL('inspect/', OUT);
await rm(out, { recursive: true, force: true });
const ran = await run('npx', ['tsx', 'scripts/inspect.ts', 'hero', 'monster:3', 'prop:brazier', '--out', '.scratch/e2e/inspect', '--json']);
const ran = await run('npm', ['run', '--silent', 'inspect', '--', 'hero', 'monster:3', 'prop:brazier', '--out', '.scratch/e2e/inspect', '--json']);
check(ran.code === 0, `inspect exits 0${ran.code ? ':\n ' + tail(ran.out) : ''}`);
const hero = JSON.parse(await readFile(new URL('hero.json', out), 'utf8'));
check(hero.triangles > 100 && hero.joints.length === 15 && hero.clips.length > 20 && hero.bounds.size[1] > 1.5, `hero: ${hero.triangles} tris, ${hero.joints.length} joints, ${hero.clips.length} clips, ${hero.bounds.size[1]} m tall`);
Expand All @@ -1253,7 +1253,7 @@ async function runTools(browserExe) {
const p = await pngSize(new URL(`${name}.png`, out));
check(p.width > 1000 && p.height > 400 && p.bytes > 10000, `${name}.png ${p.width}×${p.height}`);
}
const diff = await run('npx', ['tsx', 'scripts/inspect.ts', 'diff', 'coin', 'tree', '--out', '.scratch/e2e/inspect', '--json']);
const diff = await run('npm', ['run', '--silent', 'inspect', '--', 'diff', 'coin', 'tree', '--out', '.scratch/e2e/inspect', '--json']);
const d = JSON.parse(await readFile(new URL('diff-coin-vs-tree.json', out), 'utf8'));
check(diff.code === 0 && !d.same && d.counts.meshes[2] === 1 && d.clips.removed.includes('Spin'), `diff coin tree: meshes +${d.counts.meshes[2]}, clips -${d.clips.removed.join(',')}`);
} catch (e) {
Expand Down
2 changes: 1 addition & 1 deletion scripts/hub/package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { launchChromium } from '../browser.mjs';
import { harness, protocolRuns, serve } from '../e2e-hub.mjs';
import { strays } from '../lang-audit.mjs';
import { strays } from '../lang-strays.mjs';
import { helpExit } from '../manual.mjs';
import { engineReady } from '../page.mjs';
import { MODES } from './riftlight.mjs';
Expand Down
3 changes: 1 addition & 2 deletions scripts/lang-audit.d.mts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/** The words in a drawn string that aren't the test language (proper names, digits and symbols aside). */
export function strays(text: string, names: readonly string[]): string[];
export { strays } from './lang-strays.mjs';

export interface AuditReport {
readonly lang: string;
Expand Down
27 changes: 11 additions & 16 deletions scripts/lang-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,10 @@
// level (its card, a fight, the clear), a death. Writes .scratch/lang/audit.json and a PNG per screen.
import { mkdir, writeFile } from 'node:fs/promises';
import { launchChromium } from './browser.mjs';
import { strays } from './lang-strays.mjs';
import { engineReady, startServer } from './page.mjs';

/** The words in a drawn string that aren't the test language (proper names, digits and symbols aside). */
export function strays(text, names) {
let rest = text;
for (let prev = ''; prev !== rest; ) {
prev = rest;
rest = rest.replace(/\[[^[\]]*\]/g, ' '); // en-XA lines, innermost first (names built from lines nest)
}
for (const n of names) rest = rest.split(n.toUpperCase()).join(' ').split(n).join(' ');
return rest.match(/[A-Za-z]{2,}/g) ?? [];
}
export { strays };

/** The screens, as actions in the page (window.__RIFTLIGHT__) with an argument; each waits a little after. */
const SCREENS = [
Expand Down Expand Up @@ -59,20 +51,23 @@ export async function audit({ root, lang = 'en-XA', names = [], out = `${root}.s
await page.waitForTimeout(800);
const all = new Map();
for (const [name, act, arg] of SCREENS) {
await page.evaluate(() => {
await page.evaluate((keep) => {
const rl = window.__RIFTLIGHT__;
for (let i = 0; i < 8 && rl.ui.stack().length; i++) rl.ui.close();
if (!keep) for (let i = 0; i < 8 && rl.ui.stack().length; i++) rl.ui.close(); // the title keeps its menu
window.__PIXEL_TEXT_LOG__.clear();
});
}, name === 'title');
try {
await page.evaluate(act, arg);
} catch (e) {
report.errors.push(`${name}: ${e.message.split('\n')[0]}`);
}
// frames, not milliseconds: a software renderer may draw only a few a second
const from = await page.evaluate(() => window.__PIXEL_ENGINE__.state().frame);
await page.evaluate(() => window.__RIFTLIGHT__.realtime()); // the API's moves step by hand: give time back to the page so frames draw
// frames, not milliseconds: a software renderer may draw only a few a second. The browser's
// frames, not the engine's: a window that pauses the game stops the engine's frame count
const frames = name === 'death' ? 10 : 4;
await page.waitForFunction(([f, n]) => window.__PIXEL_ENGINE__.state().frame >= f + n, [from, frames], { timeout: 60000 }).catch(() => report.errors.push(`${name}: no frames`));
await page
.evaluate((n) => new Promise((done) => { let left = n; const next = () => (--left <= 0 ? done() : requestAnimationFrame(next)); requestAnimationFrame(next); }), frames) // prettier-ignore
.catch(() => report.errors.push(`${name}: no frames`));
if (name === 'death') await page.waitForTimeout(2600);
const texts = await page.evaluate(() => [...window.__PIXEL_TEXT_LOG__]);
const offenders = texts.filter((t) => strays(t, names).length);
Expand Down
2 changes: 2 additions & 0 deletions scripts/lang-strays.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** The words in a drawn string that aren't the test language (proper names, digits and symbols aside). */
export function strays(text: string, names?: readonly string[]): string[];
20 changes: 20 additions & 0 deletions scripts/lang-strays.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// What drawn text under a test language isn't a line (AI Hub Languages v1, *Testing with three test
// languages*; docs/game/lang.md): the one test the language audit (`npm run lang -- audit`), the AI
// Hub package's browser check (`npm run hub`) and the game's tests share.

/** en-XA's marks: brackets round a line, its vowels (and n, c) accented (src/engine/lang/lang.ts fakeLine). */
const EN_XA = /[[\]áéíóúñçÁÉÍÓÚÑ]/;

/**
* The words in a drawn string that aren't the test language (proper names, digits and symbols aside).
* Under en-XA every vowel of a line is accented, so in a string with en-XA's marks a word with a
* plain vowel is English: a word that isn't a line, or an English value filled into a line's blank
* (a wrapped line's middle rows have no brackets, but no plain vowels either). A string with no marks
* is English wherever it has a word of Latin letters (zh-XC and ar-XB have none).
*/
export function strays(text, names = []) {
let rest = text;
for (const n of [...names].sort((a, b) => b.length - a.length)) rest = rest.split(n.toUpperCase()).join(' ').split(n).join(' ');
if (!EN_XA.test(text)) return rest.match(/[A-Za-z]{2,}/g) ?? [];
return (rest.match(/[A-Za-zÀ-ɏ]{2,}/g) ?? []).filter((w) => /[aeiouAEIOU]/.test(w));
}
3 changes: 2 additions & 1 deletion scripts/lang.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ const cmd = argv[0] ?? 'help';

if (cmd === 'lines') {
if (argv.includes('--json')) {
console.log(JSON.stringify(source, null, 1));
// into a pipe stdout is asynchronous: finish writing before the process.exit below
await new Promise<void>((done) => process.stdout.write(JSON.stringify(source, null, 1) + '\n', () => done()));
} else {
const by = new Map<string, number>();
for (const id of Object.keys(lines)) by.set(areaOf(id), (by.get(areaOf(id)) ?? 0) + 1);
Expand Down
Loading
Loading