Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
8.7 kB · 220 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221/** * What a scenario changes about the game, named the way MegaMek names it. * * A daily challenge is a scenario plus a set of locked lobby settings, and * some of those settings change the rules the fight is played under. A player * has to be able to see which before they commit — plan/setup-tree.md. * * The display names are MegaMek's own, copied from * `megamek/resources/megamek/common/options/messages.properties` in the pinned * arena image. It carries a name for all 515 options, so nothing here is prose * this project wrote; an id with no entry falls back to the id, which is ugly * on purpose rather than silently blank. */
import { RULE_DESCRIPTIONS, presetNamed } from "./megamek-text";
/** What kind of override this is, which is what the card's icon says. */export type OverrideKind = "rules" | "ai";
/** * Whether a locked setting is worth telling the player about. * * Locking is not one thing. A daily pins its scenario, its map, its planetary * conditions, its teams and its rules, and only some of that changes how a * player should play. Individual initiative does — it changes the order * everything moves in. That the conditions are pinned to standard is equally * locked, equally true, and noise. * * So disclosure is a property of the lock rather than a second kind of lock: * the same node, shown or not. `visible` earns a row on the card; `silent` is * still enforced, still in the file, and simply not worth the reader's * attention. See plan/setup-tree.md. */export type Disclosure = "visible" | "silent";
/** * Which rules are worth surfacing. * * A rule is visible when it changes how the game is played rather than which * dialog a host would have clicked through. The list is deliberately explicit: * a new rule is silent until somebody decides it is worth a row, which is the * safe direction to be wrong in. */const VISIBLE_RULES = new Set([ "individual_initiative", "team_initiative", "double_blind", "tacops_start_fire", "no_clan_physical", "flamer_heat", // Whether a building has a cellar under it. On - MegaMek's default - a // building that does not declare its basement gets one rolled the first // time something heavy comes through the wall, and a hundred-ton Mek can // fall in and be crushed by the collapse. A fight across a town is a // different fight depending on the answer, so the card says which it is. "random_basements",]);
/** One thing a scenario changes, ready to draw. */export type Override = { kind: OverrideKind; /** MegaMek's display name, or the raw id when it has none. */ label: string; /** On, off, or the value a knob was set to. */ value: string; /** MegaMek marks some rules "(Unofficial)"; a scored fight should say so. */ unofficial: boolean; /** MegaMek's own explanation, for a reader who wants to know what it does. */ detail?: string; /** Whether this is worth a row on the card, or merely true. */ disclosure: Disclosure;};
/** The rules the daily scenarios currently touch. Extend as they do. */const RULE_NAMES: Record<string, string> = { individual_initiative: "(Unofficial) Individual Initiative", team_initiative: "Teams roll initiative", double_blind: "TacOps Double blind", tacops_start_fire: "TacOps Starting Fires", random_basements: "Use Random Basements",};
/** * Princess's behaviour knobs, in the order a reader cares about them: how hard * it presses, how much it values its own skin, how tightly it moves as a * group. The numbers are MegaMek's 0-10 indices. *//** * A knob's name for a reader, looked up case-folded. * * Keyed lowercase because that is how a scenario writes them and how * `mms index` carries them through. The retreat knobs are here too: a bot * that will not withdraw fights a different fight from one that will, and * that is the player's business before they commit an attempt. *//** * The five 0-10 dials a MegaMek behaviour is made of. * * Everything else in a `bot:` block - the retreat switches, the edge to run * to - sits on top of a behaviour rather than being part of one, which is why * a preset is matched on these alone. */const DIALS = new Set([ "hyperaggression", "selfpreservation", "herdmentality", "bravery", "fallshame",]);
const AI_NAMES: Record<string, string> = { hyperaggression: "Aggression", selfpreservation: "Self-preservation", herdmentality: "Herding", bravery: "Bravery", fallshame: "Fall caution", forcedwithdraw: "Forced withdrawal", withdrawto: "Withdraws to", flee: "Flees the field", fleeto: "Flees to",};
/** The shape `mms index` writes into daily.json. */export type RawOverrides = { rulesOn?: string[]; rulesOff?: string[]; ai?: { knob: string; value: string }[];};
/** * The overrides a card should draw, grouped by kind. * * Rules first and AI second, because a rule changes what is legal and a bot * knob only changes how well it is played. */export function overridesFor(raw: RawOverrides | undefined): Override[] { return allOverrides(raw).filter((o) => o.disclosure === "visible");}
/** * Every override, disclosed or not. * * The archive and anything that wants to audit a fight should be able to see * what was pinned even when the card does not show it — "locked and not worth * mentioning" is not the same as "not locked". */export function allOverrides(raw: RawOverrides | undefined): Override[] { if (!raw) return []; const out: Override[] = []; const rule = (id: string, value: string): Override => { const label = RULE_NAMES[id] ?? id; return { kind: "rules", label: label.replace("(Unofficial) ", ""), value, unofficial: label.startsWith("(Unofficial)"), detail: RULE_DESCRIPTIONS[id], disclosure: VISIBLE_RULES.has(id) ? "visible" : "silent", }; }; for (const id of raw.rulesOn ?? []) out.push(rule(id, "on")); for (const id of raw.rulesOff ?? []) out.push(rule(id, "off")); // A behaviour, not four numbers. Every knob the scenario set is matched // against MegaMek's shipped behaviours, and if they are one of them the // card names it - "Striker" says what "aggression 9, self-preservation 2" // means, to a reader who has never opened the bot config dialog. // `type` says which bot, not how it plays. It is the one entry in the block // that is not a behaviour, and naming princess to a player who has no other // bot to compare it against says nothing. const ai = (raw.ai ?? []).filter(({ knob }) => knob.toLowerCase() !== "type"); if (ai.length > 0) { // The five dials are what a behaviour *is*; the retreat settings are // separate switches on top of one. A scenario that flies MegaMek's Sniper // but refuses to withdraw is still flying Sniper, and saying so is far // more use to a player than five numbers and a flag - so the preset is // matched on the dials alone and the flags are what is printed beside it. const dials = ai.filter(({ knob }) => DIALS.has(knob.toLowerCase())); const flags = ai.filter(({ knob }) => !DIALS.has(knob.toLowerCase())); const knobs = Object.fromEntries( dials.map((a) => [a.knob.toLowerCase(), a.value]), ); const named = dials.length > 0 ? presetNamed(knobs) : null; // "Forced withdrawal off", not "Forced withdrawal false". Every other // knob is a 0-10 dial and reads as one; the two flags are switches. const spelled = ai .map(({ knob, value }) => { const name = AI_NAMES[knob.toLowerCase()] ?? knob; const shown = value === "true" ? "on" : value === "false" ? "off" : value; return `${name} ${shown}`; }) .join(" · "); const spelledFlags = flags .map(({ knob, value }) => { const name = AI_NAMES[knob.toLowerCase()] ?? knob; const shown = value === "true" ? "on" : value === "false" ? "off" : value; return `${name} ${shown}`; }) .join(" · "); out.push({ kind: "ai", label: named ?? "Custom behaviour", // A named behaviour carries only what it was changed by: the name is // the rest of the fact, and the dials behind it are on hover. An // unnamed one is spelled out in full. value: named ? spelledFlags : spelled, unofficial: false, // Always worth saying: how the opposition plays is the single thing a // player most needs to know before committing an attempt. disclosure: "visible", // The numbers are still the truth; they move into the detail rather // than off the card. detail: named ? `MegaMek's ${named} behaviour — ${spelled}.` : `${spelled}.`, }); } return out;}