Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
15 kB · 394 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395/** * The daily challenge card draws content/daily.json onto a canvas, and every * way that file can be wrong is silent: a unit the sprite map has never heard * of leaves a hex empty, a row off the board paints outside it, and a row with * more units than it has room for is one unit invisible under another. * * None of that throws, and none of it is visible in a test that cannot run a * canvas — so this checks the file against the things it points at instead. * * daily.json is generated by `mms index` from the scenario files, so the tests * that matter here are the ones a generator can still get wrong: that the * schedule is a schedule, that every unit named has art, and that the figures * are figures. * * Run with `npm test`. */import { test } from "node:test";import assert from "node:assert/strict";import { existsSync } from "node:fs";import { readFile, readdir } from "node:fs/promises";import { fileURLToPath } from "node:url";
const src = fileURLToPath(new URL("../src/", import.meta.url));const repo = fileURLToPath(new URL("../..", import.meta.url));const { challenges } = JSON.parse( await readFile(`${src}content/daily.json`, "utf8"),);
/** The six facings MegaMek's own switch has a case for; the rest face north. */const FACINGS = ["N", "NE", "SE", "S", "SW", "NW"];
/** The card paints through preview.ts, whose map is unit-art.ts. */import { candidates } from "../src/units.ts";import { SKIP, unitIndex } from "./unit-index.mjs";
test("there is a schedule, one challenge per day, in order", () => { assert.ok(challenges.length, "no challenges are scheduled"); const dates = challenges.map((c) => c.date); for (const date of dates) { assert.match(date, /^\d{4}-\d{2}-\d{2}$/, `"${date}" is not a UTC day`); } assert.equal( new Set(dates).size, dates.length, "two challenges are scheduled for the same day", ); assert.deepEqual(dates, [...dates].sort(), "the schedule is out of order");
const slugs = challenges.map((c) => c.slug); assert.equal( new Set(slugs).size, slugs.length, "two challenges share a slug", );});
test("every challenge has a name and a line about it", () => { for (const { slug, name, blurb } of challenges) { assert.ok(name?.length, `${slug} has no name`); assert.ok(blurb?.length, `${slug} has no blurb`); }});
const sprites = await unitIndex();
test( "every unit named is one the card has art for", { skip: sprites ? false : SKIP }, () => { // Collected rather than asserted one at a time. An assert inside the loop // stops at the first machine with no art, so a challenge missing two of // them reported one, and the fix looked finished when half of it was. const missing = []; for (const challenge of challenges) { for (const side of challenge.sides) { for (const unit of side.units) { if (!candidates(unit.name).some((name) => sprites.get(name))) { missing.push(`${challenge.slug}: ${unit.name}`); } } } } assert.deepEqual( missing, [], "helm's index has no art for these, so the hex draws empty", ); },);
test("every unit stands on the board, and no row is crowded enough to hide one", () => { for (const challenge of challenges) { const { columns, rows } = challenge.board; assert.ok( columns > 0 && rows > 0, `${challenge.slug}: the board has no cells`, );
// The card gives a rank's units centres at (i + 0.5) / n of the width, so // no two can land on each other while a rank holds no more units than the // board is tiles wide. A force with more than that is not crowded, it is // wrapped: the card splits it into as many equal ranks as it takes, each // one a step further from that force's own edge. // // So the condition is depth, not width. A force may fill every rank // between its edge and the middle of the field and no further — beyond // that the two forces would stand on each other instead. for (const side of challenge.sides) { const crowd = new Map(); for (const { name, row, facing } of side.units) { assert.ok( Number.isInteger(row) && row >= 0 && row < rows, `${challenge.slug}: ${name} is in row ${row}, off a board ${rows} deep`, ); assert.ok( FACINGS.includes(facing), `${challenge.slug}: ${name} faces "${facing}", which MegaMek does not act on`, ); crowd.set(row, (crowd.get(row) ?? 0) + 1); } for (const [row, units] of crowd) { const ranks = Math.ceil(units / columns); assert.ok( ranks <= rows, `${challenge.slug}: ${side.name} puts ${units} units in row ${row} of ` + `a board ${columns} wide, which wraps to ${ranks} ranks — deeper ` + `than the ${rows} the field gives a force`, ); } } }});
test("the two sides face each other", () => { // The scenario files deploy Challenger on the south edge facing N and // OPFOR on the north facing S, and the card turns the sprites to // match. A side facing the way it is standing is a side with its back to // the fight, and the picture would say so. const south = new Set(["S", "SE", "SW"]); for (const challenge of challenges) { const { rows } = challenge.board; for (const side of challenge.sides) { for (const { name, row, facing } of side.units) { if (row === 0) { assert.ok( south.has(facing), `${challenge.slug}: ${name} is on the north edge facing ${facing}, away from the fight`, ); } else if (row === rows - 1) { assert.ok( !south.has(facing), `${challenge.slug}: ${name} is on the south edge facing ${facing}, away from the fight`, ); } } } }});
test("the figures on the card are figures", () => { for (const challenge of challenges) { // Two or more: a challenge may field several opposing factions so each // can carry its own camo and deployment edge. Which of them are on the // player's side is their team, checked below. assert.ok( challenge.sides.length >= 2, `${challenge.slug} has ${challenge.sides.length} side(s), so there is nothing to fight`, ); if (challenge.sides.length > 2) { const teams = new Set(challenge.sides.map((s) => s.team)); assert.ok( !teams.has(undefined), `${challenge.slug}: more than two factions and one has no team, so MegaMek would give it its own`, ); assert.ok( teams.size >= 2, `${challenge.slug}: every faction is on the same team`, ); } for (const side of challenge.sides) { assert.ok(side.name.length, `${challenge.slug}: a force has no name`); assert.ok( Number.isInteger(side.bv) && side.bv > 0, `${challenge.slug}: ${side.name} is worth ${side.bv}`, ); assert.ok( side.units.length, `${challenge.slug}: ${side.name} fields nothing`, ); } const { from, to } = challenge.years; assert.ok( Number.isInteger(from) && Number.isInteger(to) && from > 0 && to >= from, `${challenge.slug}: "${from}-${to}" is not a range of years`, ); }});
test("every scheduled fight lands in an era the card can name", async () => { // The card's era comes from years.to and nothing else, so a challenge whose // report gave no years falls back to the year range — which is a silent loss // of the badge. Nothing scheduled should be in that state. const { eraFor } = await import("../src/content/eras.ts"); for (const { slug, years } of challenges) { assert.ok( eraFor(years.to), `${slug}: no era covers ${years.to}, so the card shows years instead`, ); }});
test("every challenge names the player's side first, and the bot's second", async () => { // The API seats a daily off two constants and never parses the scenario to // find them (services/api/src/matches/daily.rs). The lobby's daily mode // seats it off this file's own slot order instead, because that is what it // has. The two only meet at launch: a challenge whose sides came out the // other way round would draw the player's blue on the force the API hands // to Princess, and nothing would say so. const rust = await readFile( `${repo}/services/api/src/matches/daily.rs`, "utf8", ); const slot = (name) => new RegExp(`pub const ${name}: &str = "([^"]+)"`).exec(rust)?.[1]; const player = slot("PLAYER_SLOT"); assert.ok(player, "daily.rs no longer declares PLAYER_SLOT as a literal");
// The player's seat is first and named; everything after it is Princess. // There is no BOT_SLOT to check against: a challenge may field one opposing // faction or several - Bug Company fields three lances so each can carry its // own camo and edge - and which side a faction is on is its team, not its // name. for (const { slug, sides } of challenges) { assert.equal( sides[0]?.name, player, `${slug}: the first side is not the API's PLAYER_SLOT`, ); assert.ok( sides.length >= 2, `${slug}: only one side, so there is nothing to fight`, ); }});
test("every challenge is downloadable, and every download is a challenge", async () => { const published = (await readdir(`${src}../public/daily`)).filter((n) => n.endsWith(".mms"), ); for (const { slug } of challenges) { assert.ok( existsSync(`${src}../public/daily/${slug}.mms`), `${slug}: the card offers a download that is not in public/daily/`, ); } for (const name of published) { const slug = name.replace(/\.mms$/, ""); assert.ok( challenges.some((c) => c.slug === slug), `${name} is downloadable but is not a scheduled challenge`, ); }});
test("a scenario that names a game-options file ships it, both sides", async () => { // MegaMek resolves GameOptionsFile relative to the scenario, and a missing // one is silent: it logs nothing, loads the defaults and plays a different // game. Verified against MegaMek 0.51.0 - the same scenario reports // individual_initiative true with the file beside it and false without, and // neither run says a word. So the only defence is checking the file is // there, in the API's copy and in the one a player downloads. for (const { slug } of challenges) { const scenario = await readFile( `${repo}/services/api/src/matches/daily/${slug}.mms`, "utf8", ); const named = /^GameOptionsFile=(.+)$/m.exec(scenario)?.[1]?.trim(); if (!named) continue; for (const [where, dir] of [ ["the API", `${repo}/services/api/src/matches/daily`], ["the download", `${src}../public/daily`], ]) { await assert.doesNotReject( () => readFile(`${dir}/${named}`), `${slug}: names ${named} but ${where} does not ship it, so the rules it sets are silently dropped`, ); } }});
test("the published copy is the scenario the API launches", async () => { for (const { slug } of challenges) { const source = await readFile( `${repo}/services/api/src/matches/daily/${slug}.mms`, ); const published = await readFile(`${src}../public/daily/${slug}.mms`); assert.ok( source.equals(published), `${slug}: the file a player downloads is not the file the match runs; ` + `run npm run daily-public`, ); }});
test("every rule a challenge switches has MegaMek's own description", async () => { const text = await readFile(`${src}content/megamek-text.ts`, "utf8"); for (const challenge of challenges) { const on = challenge.overrides?.rulesOn ?? []; const off = challenge.overrides?.rulesOff ?? []; for (const id of [...on, ...off]) { assert.ok( text.includes(`${id}:`), `${challenge.slug}: switches ${id} but megamek-text.ts has no description for it, so the card cannot say what it does`, ); } }});
test("each side's battle value is the sum of the units in it", () => { // daily.json is generated from the scenarios and arena's report of what they // are worth, and the per-unit figures and the side total come from different // parts of that report. If they ever disagree, one of them is describing a // fight nobody is playing - and a wrong battle value renders exactly as well // as a right one, so nothing else would notice. // // This is also what says the positional join in `mms index` still lines up: // the report lists a faction in MegaMek's own order, and if that stopped // matching the file's order the units would keep their names and swap their // values, which changes no total but every row. Two of Bug Company's // Stingers differ by 124 BV on skills alone. for (const challenge of challenges) { for (const side of challenge.sides) { const known = side.units.filter((unit) => unit.bv !== undefined); if (known.length === 0) continue; assert.equal( known.length, side.units.length, `${challenge.slug} ${side.name}: some units carry a battle value and ` + `some do not, so the total cannot be checked against them`, ); const summed = known.reduce((total, unit) => total + unit.bv, 0); assert.equal( summed, side.bv, `${challenge.slug} ${side.name}: the units add up to ${summed} but ` + `the side says ${side.bv}`, ); } }});
test("a daily's crews are carried, because the battle values depend on them", () => { // BV 2.0 multiplies by gunnery and piloting, so a figure without the skills // that produced it cannot be checked by anything, here or by a reader. for (const challenge of challenges) { for (const side of challenge.sides) { for (const unit of side.units) { if (unit.bv === undefined) continue; assert.equal( typeof unit.gunnery, "number", `${challenge.slug} ${side.name} ${unit.name}: a battle value with ` + `no gunnery behind it`, ); assert.equal(typeof unit.piloting, "number", `${unit.name} piloting`); } } }});
test("a rule the card shows is one somebody chose to show", async () => { // Disclosure is a property of the lock, not a second kind of lock: a daily // pins its map and its planetary conditions too, and those are equally // locked and not worth a reader's attention. A rule is silent until it is // listed, which is the safe direction to be wrong in - a rule that changes // play and is not listed is a card that misleads, and this test is what // makes that a deliberate act rather than an oversight. const overrides = await readFile(`${src}content/overrides.ts`, "utf8"); const visible = /const VISIBLE_RULES = new Set\(\[([\s\S]*?)\]\)/.exec( overrides, ); assert.ok(visible, "VISIBLE_RULES moved or was renamed"); const listed = [...visible[1].matchAll(/"([a-z_0-9]+)"/g)].map((m) => m[1]);
for (const challenge of challenges) { for (const id of challenge.overrides?.rulesOn ?? []) { assert.ok( listed.includes(id), `${challenge.slug} switches ${id} on, but it is not in VISIBLE_RULES — ` + `the fight would change and the card would not say so`, ); } }});