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.

- {t("unconfirmed")} + {t("unconfirmed", { + count: available.length, + leagues: listFormatter.format( + available.map((item) => game(`leagues.${item}`)), + ), + })}

) : ( <> 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) => ( - {leagues(league)} + + {leagues(league)}{" "} + {!hasLevelUpTroopsFormula(league, value) && ( + + ({t("unconfirmed")}) + + )} + ))} - {/* 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. */} - - - {leagues("silver")}{" "} - ({t("unconfirmed")}) - - - - updateTroops( - "silver", - "coefficient", - Number(event.target.value), - ) - } - onFocus={selectOnFocus} - /> - - - - updateTroops( - "silver", - "ratio", - Number(event.target.value), - ) - } - onFocus={selectOnFocus} - /> - - 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;