diff --git a/README.md b/README.md index 3695c39..0dd17e7 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,9 @@ the licence MegaMek's whole `data` tree ships under. They are the preview targets in the camo editor; `web/src/scenario/units/` (thirty sprites for the scenario preview), `web/src/camo/backgrounds/` (four terrain tiles), `web/public/megamek-camo/` (41 camo patterns, built by -`web/scripts/megamek-camo.mjs`) and `web/public/eras/` (seven era icons) come +`web/scripts/megamek-camo.mjs`) and `web/public/eras/` (eight era icons, the +last of them `civil-war.png`, redrawn white at 64px from +`data/images/universe/era_civilwar.png` in the pinned arena image) come from the same tree under the same licence, credited in `web/src/content/about.json`. The faction palettes in `web/src/camo/palette.ts` are measured off those camo files by the same script, so they are derived from diff --git a/web/public/eras/civil-war.png b/web/public/eras/civil-war.png new file mode 100644 index 0000000000000000000000000000000000000000..4a250a0a8ae08482695a4e0fee667e72d3b06143 GIT binary patch literal 2743 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 literal 0 HcmV?d00001 diff --git a/web/scripts/eras.test.mjs b/web/scripts/eras.test.mjs new file mode 100644 index 0000000..bcd7103 --- /dev/null +++ b/web/scripts/eras.test.mjs @@ -0,0 +1,96 @@ +/** + * The era on the daily card is derived, not authored: eraFor() puts the newest + * design in the fight on MegaMek's era table. Every way that goes wrong is + * quiet — an off-by-one at a boundary labels a Clan Invasion fight FedCom + * Civil War, a name that is not a key of ERA_ICONS drops the symbol and shows + * a bare word, and a row out of order makes eraFor() answer with whichever era + * happens to come first. + * + * So this pins the table's shape, the boundaries against the chronology on + * sarna.net, and the names against the icon map. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { ERAS, eraFor } = await import("../src/content/eras.ts"); +const { ERA_ICONS } = await import("../src/content/trivia.ts"); + +test("the table is ordered, and only the last era is open-ended", () => { + assert.equal(ERAS.length, 14, "eras.xml has fourteen eras"); + + const codes = ERAS.map((era) => era.code); + assert.equal(new Set(codes).size, codes.length, "two eras share a code"); + + const open = ERAS.filter((era) => era.end === null); + assert.equal(open.length, 1, "exactly one era has no end date"); + assert.equal(ERAS.at(-1), open[0], "the open-ended era is not last"); + + let previous = 0; + for (const era of ERAS.slice(0, -1)) { + assert.ok( + era.end > previous, + `${era.code} ends in ${era.end}, at or before the era before it`, + ); + previous = era.end; + } +}); + +test("every era name has an icon the site ships", () => { + for (const era of ERAS) { + assert.ok( + era.name in ERA_ICONS, + `${era.code}: "${era.name}" is not a key of ERA_ICONS, so it has no symbol`, + ); + assert.ok( + ERA_ICONS[era.name] !== null, + `${era.code}: "${era.name}" is mapped to no icon`, + ); + } +}); + +test("a year lands in the era sarna puts it in, on both sides of every boundary", () => { + // The chronology at https://www.sarna.net/wiki/BattleTech_eras, first and + // last year of each era, walked in order. Both ends of every pair, because a + // <= that should be a < is right on one side and wrong on the other. + const canon = [ + [2005, "Age of War"], + [2570, "Age of War"], + [2571, "Star League"], + [2780, "Star League"], + [2781, "Early Succession War"], + [2900, "Early Succession War"], + [2901, "Late Succession War - LosTech"], + [3019, "Late Succession War - LosTech"], + [3020, "Late Succession War - Renaissance"], + [3049, "Late Succession War - Renaissance"], + [3050, "Clan Invasion"], + [3061, "Clan Invasion"], + [3062, "FedCom Civil War"], + [3067, "FedCom Civil War"], + [3068, "Jihad"], + [3080, "Jihad"], + [3081, "Republic Age"], + [3130, "Republic Age"], + [3131, "Dark Age"], + [3150, "Dark Age"], + [3151, "ilClan"], + ]; + + for (const [year, name] of canon) { + assert.equal(eraFor(year)?.name, name, `${year} is not ${name}`); + } + + // Open-ended, so a year past the table is still the last era rather than + // nothing at all. + assert.equal(eraFor(9999)?.name, "ilClan", "the last era stopped being open"); +}); + +test("a year that is not a year has no era", () => { + // mms index writes 0 when arena's report measured nothing, and the card + // falls back to the year range on null rather than naming Pre-Star League. + for (const year of [0, -1, Number.NaN, 3050.5]) { + assert.equal(eraFor(year), null, `${year} was given an era`); + } +}); diff --git a/web/src/content/about.json b/web/src/content/about.json index 429b02a..61a5633 100644 --- a/web/src/content/about.json +++ b/web/src/content/about.json @@ -58,12 +58,12 @@ }, { "p": [ - "The era symbols on the match loading screen are Catalyst Game Labs' era icons, sourced from the MegaMek project's ", + "The era symbols on the match loading screen and the daily challenge card are Catalyst Game Labs' era icons, sourced from the MegaMek project's ", { "a": "MekBay", "href": "https://mekbay.com/" }, - ". The trivia shown beside them cites the community-run ", + " and its own data files. The trivia shown beside them cites the community-run ", { "a": "BattleTechWiki", "href": "https://www.sarna.net/" diff --git a/web/src/content/eras.ts b/web/src/content/eras.ts new file mode 100644 index 0000000..a871b09 --- /dev/null +++ b/web/src/content/eras.ts @@ -0,0 +1,70 @@ +/** + * MegaMek's era table, and the era a year falls in. + * + * The daily card names the era its fight belongs to, and the only input it has + * for that is the years already in daily.json. The newest design in a fight is + * the earliest the fight could be set, so `years.to` is what picks the era — + * a fight fielding a 3063 machine is not a Succession Wars fight, whatever the + * oldest chassis in it is. + * + * The boundaries are MegaMek's own, copied from `data/universe/eras.xml` in + * the pinned arena image, so they are the same ones the game uses. `end` is + * the era's last year: MegaMek's `docs/Universe Modding/Eras.txt` says a bare + * `yyyy` end date means the last day of that year, and every canon boundary + * below matches the chronology on sarna.net's BattleTech eras page. + * + * `name` is what the card shows and the key into `ERA_ICONS`, and it is not + * always MegaMek's own ``: + * + * - MegaMek's "Civil War" is the FedCom Civil War — sarna's Federated + * Commonwealth Civil War, 3062 to 3067 — and "Civil War" alone does not say + * which one. + * - MegaMek splits the Republic at 3100 into Early and Late Republic. Sarna's + * chronology has one Republic Age, 3081 to 3130, so both rows carry that + * name; MegaMek's own docs say era names need not be unique. + * - Sarna writes the Succession War sub-eras in the singular, and spells the + * last one Dark Age rather than Dark Ages. + * - Sarna groups MegaMek's two pre-2005 eras under Pre-Star League, which is + * already the site's spelling, and no BattleMech is old enough to fall in + * either of them anyway. + */ + +/** One era: what to call it, and the last year it covers. */ +export type Era = { + /** The era's code in MegaMek's `eras.xml`, which is the row to check against. */ + code: string; + /** The name shown on the card, and a key of `ERA_ICONS`. */ + name: string; + /** The era's last year, or null for the open-ended one at the end. */ + end: number | null; +}; + +/** MegaMek's eras, oldest first, one row per row of `eras.xml`. */ +export const ERAS: readonly Era[] = [ + { code: "PSF", name: "Pre-Star League", end: 1950 }, + { code: "ESF", name: "Pre-Star League", end: 2004 }, + { code: "AOW", name: "Age of War", end: 2570 }, + { code: "SL", name: "Star League", end: 2780 }, + { code: "ESW", name: "Early Succession War", end: 2900 }, + { code: "LSWL", name: "Late Succession War - LosTech", end: 3019 }, + { code: "LSWR", name: "Late Succession War - Renaissance", end: 3049 }, + { code: "CI", name: "Clan Invasion", end: 3061 }, + { code: "CW", name: "FedCom Civil War", end: 3067 }, + { code: "JHD", name: "Jihad", end: 3080 }, + { code: "EREP", name: "Republic Age", end: 3100 }, + { code: "LREP", name: "Republic Age", end: 3130 }, + { code: "DAGE", name: "Dark Age", end: 3150 }, + { code: "ILC", name: "ilClan", end: null }, +]; + +/** + * The era a year falls in. + * + * Null for a year that is not one: `mms index` writes a year of 0 when arena's + * report gave it nothing to work with, and a card that showed Pre-Star League + * for that would be inventing an answer rather than leaving the gap. + */ +export function eraFor(year: number): Era | null { + if (!Number.isInteger(year) || year <= 0) return null; + return ERAS.find((era) => era.end === null || year <= era.end) ?? null; +} diff --git a/web/src/content/trivia.ts b/web/src/content/trivia.ts index 9af5491..67c7a64 100644 --- a/web/src/content/trivia.ts +++ b/web/src/content/trivia.ts @@ -20,21 +20,30 @@ export type Trivia = { }; /** - * The badge icon for each era the file may use, or null for the eras that + * The badge icon for each era the site may name, or null for the eras that * have none. The files are Catalyst Game Labs' era symbols, taken from the - * MegaMek project's MekBay (About credits them); one symbol covers everything - * up to the Star League, so three early eras share it. This map is also the - * closed set of eras — the trivia test rejects a row whose era is not a key, - * because a typo here would render straight into the badge. + * MegaMek project (About credits them); there are fewer symbols than eras, so + * several eras share one — everything up to the Star League has the same + * symbol, and so does everything from the Republic Age on. + * + * This map is also the closed set of era names, for the two things that show + * one: the trivia test rejects a row whose era is not a key, and the era table + * in `eras.ts` names the era of a daily challenge with a key from here. A typo + * either side would render straight into the badge. */ export const ERA_ICONS: Record = { "Pre-Star League": "/eras/age-of-war-star-league.png", "Age of War": "/eras/age-of-war-star-league.png", "Star League": "/eras/age-of-war-star-league.png", "Succession Wars": "/eras/succession-wars.png", + "Early Succession War": "/eras/succession-wars.png", + "Late Succession War - LosTech": "/eras/succession-wars.png", + "Late Succession War - Renaissance": "/eras/succession-wars.png", "Clan Homeworlds": "/eras/clan-homeworlds.png", "Clan Invasion": "/eras/clan-invasion.png", + "FedCom Civil War": "/eras/civil-war.png", Jihad: "/eras/jihad.png", + "Republic Age": "/eras/republic-dark-age.png", "Dark Age": "/eras/republic-dark-age.png", ilClan: "/eras/ilclan.png", "Real World": null,