diff --git a/e2e/phase-one.spec.ts b/e2e/phase-one.spec.ts
index 9b2eba2..702a674 100644
--- a/e2e/phase-one.spec.ts
+++ b/e2e/phase-one.spec.ts
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
+import { defaultLevelUpParameters } from "../src/lib/level-up";
import * as OTPAuth from "otpauth";
test.describe.configure({ mode: "serial" });
@@ -403,6 +404,58 @@ test("Progression is a Référentiels reference and keeps Silver unconfirmed", a
).toBeVisible();
});
+// Bloc 98/A: the bug this bloc fixes, end to end and in the order it was
+// reported — an admin fills a league in, saves, and the public reference is
+// still telling players the league is unavailable. Availability now comes from
+// the stored values, so saving is all it takes.
+test("Bloc 98/A: a league becomes available publicly as soon as an admin fills it in", async ({
+ page,
+}) => {
+ test.setTimeout(60_000);
+ const leagueGroup = page.getByRole("group", { name: "Ligue" });
+ const endpoint = "/api/admin/guides/references/level-up";
+
+ await page.goto("/referentiels/level-up");
+ await leagueGroup.getByRole("button", { name: "Argent" }).click();
+ await expect(page.getByRole("status")).toContainText("non encore confirmée");
+ await expect(page.getByRole("table")).toHaveCount(0);
+
+ await b90EnsureRoot(page);
+ await b90Login(page, B90_ROOT.username, B90_ROOT.password);
+ const filled = await page.request.put(endpoint, {
+ data: {
+ ...defaultLevelUpParameters,
+ troops: {
+ ...defaultLevelUpParameters.troops,
+ silver: { coefficient: 30, ratio: 1.24 },
+ },
+ },
+ });
+ expect(filled.status()).toBe(200);
+
+ await page.goto("/referentiels/level-up");
+ await leagueGroup.getByRole("button", { name: "Argent" }).click();
+ await expect(page.getByRole("table").first()).toBeVisible();
+ // The saved values are what the table is built from: level 2 is
+ // coefficient × ratio² = 30 × 1.24² = 46.
+ await expect(
+ page.getByRole("row").nth(2).getByRole("cell").nth(2),
+ ).toHaveText("46");
+
+ // Putting the league back to blank must be savable too — the admin route
+ // used to reject any zero, so the reference could not be saved at all while
+ // a league was still unconfirmed (Bloc 98/A). This also restores the seeded
+ // state for the rest of the suite.
+ const blanked = await page.request.put(endpoint, {
+ data: defaultLevelUpParameters,
+ });
+ expect(blanked.status()).toBe(200);
+ await page.goto("/referentiels/level-up");
+ await leagueGroup.getByRole("button", { name: "Argent" }).click();
+ await expect(page.getByRole("status")).toContainText("Ligues disponibles :");
+ await expect(page.getByRole("table")).toHaveCount(0);
+});
+
test("calculator pages only repeat names in their navigation tabs", async ({
page,
}) => {
@@ -667,9 +720,7 @@ test("Ranking converts position and percentage into league ranges", async ({
await rankingLeagueGroup.getByRole("button", { name: "Bronze" }).click();
// Bloc 92/A11y: the ranking placeholder no longer carries its own
// role="status" (it sits inside a permanent aria-live region); match its text.
- await expect(
- page.getByText(/à définir dans l’administration/),
- ).toBeVisible();
+ await expect(page.getByText(/à définir dans l’administration/)).toBeVisible();
});
test("Skills exposes gem distributions and exact templar costs", async ({
diff --git a/messages/de.json b/messages/de.json
index 6e0320f..59c0bec 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -651,7 +651,7 @@
"name": "Stufenaufstieg",
"league": "Liga",
"select-league": "Wähle eine Liga, um die Referenztabelle anzuzeigen.",
- "unconfirmed": "⚠️ Die Truppenformel ist für diese Liga noch nicht bestätigt — derzeit sind nur Legende, Diamant, Platin, Bronze und Gold verfügbar.",
+ "unconfirmed": "⚠️ Die Truppenformel ist für diese Liga noch nicht bestätigt. {count, plural, =0 {Derzeit ist keine Liga verfügbar.} other {Verfügbare Ligen: {leagues}.}}",
"columns": {
"level": "Stufe",
"xp": "Benötigte XP",
diff --git a/messages/en.json b/messages/en.json
index 0233ddd..647f8fa 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -651,7 +651,7 @@
"name": "Level Up",
"league": "League",
"select-league": "Choose a league to display the reference table.",
- "unconfirmed": "⚠️ The troop formula is not yet confirmed for this league — only Legend, Diamond, Platinum, Bronze and Gold are currently available.",
+ "unconfirmed": "⚠️ The troop formula is not yet confirmed for this league. {count, plural, =0 {No league is available at the moment.} other {Available leagues: {leagues}.}}",
"columns": {
"level": "Level",
"xp": "Required XP",
diff --git a/messages/es.json b/messages/es.json
index d2924f5..2b5f2fe 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -651,7 +651,7 @@
"name": "Subida de Nivel",
"league": "Liga",
"select-league": "Elige una liga para mostrar la tabla de referencia.",
- "unconfirmed": "⚠️ La fórmula de tropas aún no está confirmada para esta liga — actualmente solo están disponibles Leyenda, Diamante, Platino, Bronce y Oro.",
+ "unconfirmed": "⚠️ La fórmula de tropas aún no está confirmada para esta liga. {count, plural, =0 {Por ahora no hay ninguna liga disponible.} other {Ligas disponibles: {leagues}.}}",
"columns": {
"level": "Nivel",
"xp": "XP requerida",
diff --git a/messages/fr.json b/messages/fr.json
index 87601be..5281cdb 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -651,7 +651,7 @@
"name": "Progression",
"league": "Ligue",
"select-league": "Choisis une ligue pour afficher le référentiel.",
- "unconfirmed": "⚠️ Formule de troupes non encore confirmée pour cette ligue — seules Légende, Diamant, Platine, Bronze et Or sont disponibles pour le moment.",
+ "unconfirmed": "⚠️ Formule de troupes non encore confirmée pour cette ligue. {count, plural, =0 {Aucune ligue n'est disponible pour le moment.} other {Ligues disponibles : {leagues}.}}",
"columns": {
"level": "Niveau",
"xp": "XP requis",
diff --git a/messages/tr.json b/messages/tr.json
index 6f19403..dc9207c 100644
--- a/messages/tr.json
+++ b/messages/tr.json
@@ -651,7 +651,7 @@
"name": "Seviye Atlama",
"league": "Lig",
"select-league": "Referans tablosunu görüntülemek için bir lig seç.",
- "unconfirmed": "⚠️ Birlik formülü bu lig için henüz doğrulanmadı — şu anda yalnızca Efsane, Elmas, Platin, Bronz ve Altın kullanılabilir.",
+ "unconfirmed": "⚠️ Birlik formülü bu lig için henüz doğrulanmadı. {count, plural, =0 {Şu anda hiçbir lig mevcut değil.} other {Mevcut ligler: {leagues}.}}",
"columns": {
"level": "Seviye",
"xp": "Gerekli XP",
diff --git a/src/app/api/admin/guides/references/level-up/route.ts b/src/app/api/admin/guides/references/level-up/route.ts
index fd1a015..83bc085 100644
--- a/src/app/api/admin/guides/references/level-up/route.ts
+++ b/src/app/api/admin/guides/references/level-up/route.ts
@@ -1,6 +1,9 @@
import { NextResponse } from "next/server";
import { authorizedSession, forbiddenResponse } from "@/auth/api-authorization";
-import { parseLevelUpParameters } from "@/lib/level-up";
+import {
+ isSavableLevelUpParameters,
+ parseLevelUpParameters,
+} from "@/lib/level-up";
import { saveFormulaParameters } from "@/services/formula-parameters-admin";
export async function PUT(request: Request) {
@@ -9,15 +12,7 @@ export async function PUT(request: Request) {
const parameters = parseLevelUpParameters(
await request.json().catch(() => null),
);
- const numbers = [
- parameters.xp.base,
- parameters.xp.ratio,
- ...Object.values(parameters.troops).flatMap(({ coefficient, ratio }) => [
- coefficient,
- ratio,
- ]),
- ];
- if (numbers.some((value) => !Number.isFinite(value) || value <= 0))
+ if (!isSavableLevelUpParameters(parameters))
return NextResponse.json({ error: "invalid_parameters" }, { status: 400 });
await saveFormulaParameters({
calculatorSlug: "level-up",
diff --git a/src/components/level-up-reference.test.tsx b/src/components/level-up-reference.test.tsx
index a051aaa..5e3fa88 100644
--- a/src/components/level-up-reference.test.tsx
+++ b/src/components/level-up-reference.test.tsx
@@ -1,4 +1,10 @@
-import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ within,
+} from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { NextIntlClientProvider } from "next-intl";
import messages from "../../messages/fr.json";
@@ -145,4 +151,68 @@ describe("LevelUpReference", () => {
screen.getByRole("link", { name: /Taux de gain d’XP$/ }),
).toHaveAttribute("href", "/tools/combat?open=xp");
});
+
+ // Bloc 98/A: the reported bug, end to end on the public side — Argent's
+ // coefficient and ratio were saved in the admin, and the reference still
+ // told the player the league was unavailable.
+ it("Bloc98/A: shows the table for a league an admin has just filled in", () => {
+ const parameters = {
+ ...defaultLevelUpParameters,
+ troops: {
+ ...defaultLevelUpParameters.troops,
+ silver: { coefficient: 30, ratio: 1.24 },
+ },
+ };
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Argent" }));
+ expect(screen.getAllByRole("row")).toHaveLength(62);
+ expect(screen.queryByRole("status")).toBeNull();
+ // And the stored values are what the table is built from: level 2 is
+ // coefficient × ratio² = 30 × 1.24² = 46. A league merely let through the
+ // display check, with its formula still refused, would show 0 here.
+ const levelTwo = within(screen.getAllByRole("row")[2]).getAllByRole("cell");
+ expect(levelTwo[0]).toHaveTextContent("2");
+ expect(levelTwo[2]).toHaveTextContent("46");
+ });
+
+ it("Bloc98/A: names the leagues that really are available, not a fixed list", () => {
+ // With the shipped defaults Argent is the only one missing, so the notice
+ // must name the other five — and never Argent itself.
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Argent" }));
+ const notice = screen.getByRole("status");
+ expect(notice).toHaveTextContent(
+ "Ligues disponibles : Bronze, Or, Platine, Diamant et Légende.",
+ );
+ expect(notice).not.toHaveTextContent("Argent");
+ });
+
+ it("Bloc98/A: drops a league from that list as soon as its values are cleared", () => {
+ // The same sentence, recomputed: clearing Légende must remove it from the
+ // notice, which a hard-coded list of names could never do.
+ const parameters = {
+ ...defaultLevelUpParameters,
+ troops: {
+ ...defaultLevelUpParameters.troops,
+ legend: { coefficient: 0, ratio: 0 },
+ },
+ };
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Légende" }));
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Ligues disponibles : Bronze, Or, Platine et Diamant.",
+ );
+ });
});
diff --git a/src/components/level-up-reference.tsx b/src/components/level-up-reference.tsx
index bff5ed6..843f99f 100644
--- a/src/components/level-up-reference.tsx
+++ b/src/components/level-up-reference.tsx
@@ -1,9 +1,11 @@
"use client";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import { formatGameNumber } from "../lib/format";
import {
+ availableLevelUpLeagues,
+ hasLevelUpTroopsFormula,
levelUpChestAt,
levelUpTroopsAt,
xpAt,
@@ -75,12 +77,19 @@ export function LevelUpReference({
parameters: LevelUpParameters;
}) {
const t = useTranslations("level-up");
+ const game = useTranslations("game");
const xpGainRate = useTranslations("xp-gain-rate");
const crossReference = useTranslations("crossReference");
const levelUpReference = referenceCatalog.find(
(item) => item.slug === "level-up",
)!;
const [league, setLeague] = useSyncedLeague();
+ const available = availableLevelUpLeagues(parameters);
+ // Intl handles the "A, B et C" joining per language, so the sentence below
+ // needs no hand-written separator in any of the 5 locales.
+ const listFormatter = new Intl.ListFormat(useLocale(), {
+ type: "conjunction",
+ });
const [page, setPage] = useState(0);
const start = page * parameters.pageSize + 1;
const levels = Array.from(
@@ -109,9 +118,18 @@ export function LevelUpReference({
{t("select-league")}
- ) : league === "silver" ? (
+ ) : !hasLevelUpTroopsFormula(league, parameters) ? (
+ // Bloc 98/A: a league is unavailable because its formula is missing
+ // from the parameters, not because of its name — and the leagues it
+ // names are the ones that really do have one, so this sentence can no
+ // longer contradict what an admin has just saved.
) : (
<>
diff --git a/src/components/named-parameters-editor.test.tsx b/src/components/named-parameters-editor.test.tsx
index 3079e88..6e34ff8 100644
--- a/src/components/named-parameters-editor.test.tsx
+++ b/src/components/named-parameters-editor.test.tsx
@@ -1,4 +1,10 @@
-import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
+import {
+ cleanup,
+ fireEvent,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithIntl as render } from "../test/render-with-intl";
import { defaultCityParameters } from "../lib/city-parameters";
@@ -214,6 +220,72 @@ describe("named formula parameter editors", () => {
expect(body.troops.silver).toEqual({ coefficient: 12.5, ratio: 1.1 });
});
+ // Bloc 98/C: one row per league, in the game's own progression order —
+ // Silver used to be a hand-written row appended after the five "confirmed"
+ // ones, so it sat last, after Légende.
+ it("Bloc98/C: lists every league in game progression order", () => {
+ render();
+ const rows = screen.getAllByRole("row").slice(1); // drop the header row
+ expect(
+ rows.map((row) =>
+ within(row).getAllByRole("cell")[0].textContent?.trim(),
+ ),
+ ).toEqual([
+ "Bronze",
+ expect.stringContaining("Argent"),
+ "Or",
+ "Platine",
+ "Diamant",
+ "Légende",
+ ]);
+ });
+
+ // Bloc 98/B: the note stayed next to Argent for good, even once values had
+ // been entered and saved. It now follows what is actually stored.
+ it("Bloc98/B: drops the 'not confirmed' note once a league has values", () => {
+ // The Bloc42/B test above renders the shipped defaults and finds the note
+ // on Argent; the same editor, given values for Argent, must not show it —
+ // it used to stay for good, whatever had been entered and saved.
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("spinbutton", { name: "Argent Coefficient" }),
+ ).toHaveValue(30);
+ expect(screen.queryByText(/Formule de troupes non confirmée/)).toBeNull();
+ });
+
+ it("Bloc98/B: puts that note on any league left empty, not only on Argent", () => {
+ render(
+ ,
+ );
+ const noted = screen
+ .getAllByText(/Formule de troupes non confirmée/)
+ .map((note) =>
+ note
+ .closest("td")
+ ?.textContent?.replace(/\(.*\)/, "")
+ .trim(),
+ );
+ expect(noted).toEqual(["Argent", "Légende"]);
+ });
+
it("Bloc35 8.1: narrows the per-skill/per-league value columns (never exceed 100%)", () => {
render();
const valueCell = screen
diff --git a/src/components/named-parameters-editor.tsx b/src/components/named-parameters-editor.tsx
index c8d3d8d..2556c2c 100644
--- a/src/components/named-parameters-editor.tsx
+++ b/src/components/named-parameters-editor.tsx
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { cityLeagues, type CityParameters } from "../lib/city-parameters";
import type { TemplarParameters } from "../lib/templar-parameters";
import {
- confirmedLevelUpLeagues,
+ hasLevelUpTroopsFormula,
type LevelUpParameters,
} from "../lib/level-up";
import type { XpTier } from "../lib/combat-calculators";
@@ -279,9 +279,24 @@ export function LevelUpParametersEditor({
- {confirmedLevelUpLeagues.map((league) => (
+ {/* Bloc 42/B: every league gets a real coefficient/ratio field —
+ AGENTS.md requires unconfirmed data to stay admin-editable
+ with a default value. Bloc 98/A+C: one row per league, taken
+ from the shared league list in game progression order, so
+ Silver is no longer a hand-written row appended after the
+ five "confirmed" ones. The note next to a league's name is
+ now driven by what is actually stored, so it disappears as
+ soon as that league's two values are filled in (Bloc 98/B). */}
+ {allLeagues.map((league) => (
))}
- {/* Bloc 42/B: Silver's troop formula is still unconfirmed
- (levelUpTroopsAt keeps returning null for it), but
- AGENTS.md requires unconfirmed data to stay admin-editable
- with a default value — a real input replaces the previous
- static "not confirmed" text, so an admin can start filling
- it in once the values are known. */}
-
diff --git a/src/lib/level-up.test.ts b/src/lib/level-up.test.ts
index 5f606bd..606551c 100644
--- a/src/lib/level-up.test.ts
+++ b/src/lib/level-up.test.ts
@@ -1,5 +1,26 @@
+import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
-import { levelUpChestAt, levelUpTroopsAt, xpAt } from "./level-up";
+import {
+ availableLevelUpLeagues,
+ defaultLevelUpParameters,
+ hasLevelUpTroopsFormula,
+ isSavableLevelUpParameters,
+ levelUpChestAt,
+ levelUpTroopsAt,
+ xpAt,
+} from "./level-up";
+import { leagues } from "./player-settings";
+
+/** defaultLevelUpParameters with one league's formula replaced. */
+function withTroops(
+ league: (typeof leagues)[number],
+ troops: { coefficient: number; ratio: number },
+) {
+ return {
+ ...defaultLevelUpParameters,
+ troops: { ...defaultLevelUpParameters.troops, [league]: troops },
+ };
+}
describe("Level Up reference", () => {
it.each([
@@ -11,7 +32,7 @@ describe("Level Up reference", () => {
] as const)("uses the confirmed %s troop formula", (league, expected) =>
expect(levelUpTroopsAt(2, league)).toBeCloseTo(expected),
);
- it("keeps Silver explicitly unconfirmed", () =>
+ it("has nothing to show for a league whose formula was never filled in", () =>
expect(levelUpTroopsAt(2, "silver")).toBeNull());
it("uses universal XP and the five-chest ten-level cycle", () => {
expect(xpAt(1)).toBe(50);
@@ -22,3 +43,111 @@ describe("Level Up reference", () => {
expect(levelUpChestAt(60)).toBe(0);
});
});
+
+// Bloc 98/A: an admin could fill in Silver's coefficient and ratio, save them,
+// and the public reference would still refuse the league — availability was a
+// list of league names in the code, not a question asked of the data. These
+// tests are written over whichever league is passed in, never over "silver",
+// because the fix is meant to hold for any league.
+describe("which leagues the Progression reference can show", () => {
+ it("accepts any league once its two values are stored", () => {
+ // The reported bug, in one assertion: Silver ships unfilled and unusable…
+ expect(hasLevelUpTroopsFormula("silver")).toBe(false);
+ expect(levelUpTroopsAt(2, "silver")).toBeNull();
+
+ // …and an admin filling it in is all it takes to make it work.
+ const filled = withTroops("silver", { coefficient: 30, ratio: 1.24 });
+ expect(hasLevelUpTroopsFormula("silver", filled)).toBe(true);
+ expect(levelUpTroopsAt(2, "silver", filled)).toBeCloseTo(30 * 1.24 ** 2);
+ });
+
+ it("drops a league again when its values are cleared", () => {
+ // The same rule in reverse, on a league that has always been confirmed —
+ // proof that nothing is hard-coded on either side.
+ const cleared = withTroops("legend", { coefficient: 0, ratio: 0 });
+ expect(hasLevelUpTroopsFormula("legend", cleared)).toBe(false);
+ expect(levelUpTroopsAt(2, "legend", cleared)).toBeNull();
+ // Level 1 is a flat 200 for a league that has a formula; a league without
+ // one must not sneak that value through either.
+ expect(levelUpTroopsAt(1, "legend", cleared)).toBeNull();
+ });
+
+ it.each([
+ ["a missing coefficient", { coefficient: 0, ratio: 1.24 }],
+ ["a missing ratio", { coefficient: 30, ratio: 0 }],
+ ["a negative coefficient", { coefficient: -30, ratio: 1.24 }],
+ ["a NaN ratio", { coefficient: 30, ratio: Number.NaN }],
+ ])("refuses %s, which could never produce a real curve", (_, troops) =>
+ expect(
+ hasLevelUpTroopsFormula("silver", withTroops("silver", troops)),
+ ).toBe(false),
+ );
+
+ it("lists the available leagues in game progression order", () => {
+ // Bloc 98/C: Bronze → Légende, the order of the shared league list.
+ expect(availableLevelUpLeagues()).toEqual([
+ "bronze",
+ "gold",
+ "platinum",
+ "diamond",
+ "legend",
+ ]);
+ const filled = withTroops("silver", { coefficient: 30, ratio: 1.24 });
+ expect(availableLevelUpLeagues(filled)).toEqual([...leagues]);
+ });
+
+ it("names no league in the logic itself", () => {
+ // What made the bug possible, and what must not come back: a league name
+ // written into the code deciding what the reference will show. Comments
+ // may still name Silver — the one above does.
+ for (const file of [
+ "src/lib/level-up.ts",
+ "src/components/level-up-reference.tsx",
+ ]) {
+ const code = readFileSync(file, "utf8")
+ .split("\n")
+ .filter((line) => !line.trimStart().startsWith("//"))
+ .join("\n");
+ for (const league of leagues)
+ expect(code, `${file} still names ${league}`).not.toContain(
+ `"${league}"`,
+ );
+ }
+ });
+});
+
+// Bloc 98/A: the admin route used to refuse any payload containing a zero,
+// which meant the whole Progression reference could not be saved at all while
+// one league was still blank — and on a fresh install Silver always is, so the
+// first save an admin ever attempted came back 400.
+describe("saving Progression parameters", () => {
+ it("accepts a league left blank, which is how a league stays unconfirmed", () =>
+ expect(isSavableLevelUpParameters(defaultLevelUpParameters)).toBe(true));
+
+ it("accepts that same league once it is filled in", () =>
+ expect(
+ isSavableLevelUpParameters(
+ withTroops("silver", { coefficient: 30, ratio: 1.24 }),
+ ),
+ ).toBe(true));
+
+ it.each([
+ ["half filled in", { coefficient: 30, ratio: 0 }],
+ ["half filled in the other way", { coefficient: 0, ratio: 1.24 }],
+ ["negative", { coefficient: -30, ratio: 1.24 }],
+ ["not a number", { coefficient: Number.NaN, ratio: 1.24 }],
+ ])("still refuses a %s formula", (_, troops) =>
+ expect(isSavableLevelUpParameters(withTroops("silver", troops))).toBe(
+ false,
+ ),
+ );
+
+ it("still requires the XP curve, which no league can do without", () => {
+ expect(
+ isSavableLevelUpParameters({
+ ...defaultLevelUpParameters,
+ xp: { base: 0, ratio: 1.3 },
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/src/lib/level-up.ts b/src/lib/level-up.ts
index e627c1a..8e8c1c4 100644
--- a/src/lib/level-up.ts
+++ b/src/lib/level-up.ts
@@ -1,20 +1,11 @@
import { leagues, type League } from "./player-settings";
-export const confirmedLevelUpLeagues = [
- "bronze",
- "gold",
- "platinum",
- "diamond",
- "legend",
-] as const;
-export type ConfirmedLevelUpLeague = (typeof confirmedLevelUpLeagues)[number];
export type LevelUpParameters = {
xp: { base: number; ratio: number };
- // Bloc 42/B: widened to every league, not just the 5 confirmed ones —
- // Silver's formula is still unconfirmed (levelUpTroopsAt keeps returning
- // null for it below) but AGENTS.md requires unconfirmed data to stay
- // admin-editable with a default value, which needs a slot in the type to
- // edit. Silver defaults to {0, 0} rather than a confirmed league's value.
+ // Bloc 42/B: every league has a slot, so any of them can be filled in from
+ // the admin — AGENTS.md requires unconfirmed data to stay editable with a
+ // default value. Bloc 98/A: {0, 0} is that default, and it is what marks a
+ // league as not yet confirmed (see hasLevelUpTroopsFormula below).
troops: Record;
maxLevel: number;
columnSize: number;
@@ -38,6 +29,62 @@ export const defaultLevelUpParameters: LevelUpParameters = {
chestInterval: 10,
};
+// Bloc 98/A: which leagues have a troop formula is read from the parameters,
+// never from a list of league names. The list that used to sit here — and the
+// `league === "silver"` test that used to open levelUpTroopsAt — meant an admin
+// could fill in Silver's coefficient and ratio, save them, and still be told by
+// the public reference that Silver was unavailable: the values were in the
+// database and nothing ever looked at them. Any league an admin fills in now
+// simply works, and one that is emptied goes back to unconfirmed on its own.
+export function hasLevelUpTroopsFormula(
+ league: League,
+ parameters: LevelUpParameters = defaultLevelUpParameters,
+): boolean {
+ const formula = parameters.troops[league];
+ // Zero is what an unfilled league carries (see defaultLevelUpParameters) and
+ // is also the one value that could never be a real formula: a coefficient of
+ // 0 yields 0 troops at every level, a ratio of 0 yields 0 from level 2 on.
+ return (
+ Number.isFinite(formula?.coefficient) &&
+ Number.isFinite(formula?.ratio) &&
+ formula.coefficient > 0 &&
+ formula.ratio > 0
+ );
+}
+
+/** The leagues a player can actually consult, in game progression order. */
+export function availableLevelUpLeagues(
+ parameters: LevelUpParameters = defaultLevelUpParameters,
+): League[] {
+ return leagues.filter((league) =>
+ hasLevelUpTroopsFormula(league, parameters),
+ );
+}
+
+/**
+ * Whether these parameters can be stored as they are.
+ *
+ * Bloc 98/A: a league's troop pair is either filled in (both > 0) or not known
+ * yet (both 0 — the default, and what marks the league unconfirmed for the
+ * public reference). The admin route used to demand that EVERY number be > 0,
+ * which made the whole Progression reference unsavable for as long as any one
+ * league was still blank — on a fresh install, that is Silver, so the very
+ * first save an admin attempted came back 400. A half-filled pair is refused
+ * too: it is neither a formula nor a blank slot.
+ */
+export function isSavableLevelUpParameters(
+ parameters: LevelUpParameters,
+): boolean {
+ const positive = (value: number) => Number.isFinite(value) && value > 0;
+ if (!positive(parameters.xp.base) || !positive(parameters.xp.ratio))
+ return false;
+ return Object.values(parameters.troops).every(({ coefficient, ratio }) => {
+ if (!Number.isFinite(coefficient) || !Number.isFinite(ratio)) return false;
+ if (coefficient < 0 || ratio < 0) return false;
+ return (coefficient === 0) === (ratio === 0);
+ });
+}
+
export function parseLevelUpParameters(value: unknown): LevelUpParameters {
if (!value || typeof value !== "object")
return structuredClone(defaultLevelUpParameters);
@@ -74,7 +121,7 @@ export function levelUpTroopsAt(
league: League,
parameters = defaultLevelUpParameters,
): number | null {
- if (league === "silver") return null;
+ if (!hasLevelUpTroopsFormula(league, parameters)) return null;
if (level === 1) return 200;
const formula = parameters.troops[league];
return formula.coefficient * formula.ratio ** level;