From a20a845eae332e6c62fc644366169038a93cd0ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 19:38:22 +0000 Subject: [PATCH] =?UTF-8?q?Bloc=2098=20:=20Progression=20=E2=80=94=20une?= =?UTF-8?q?=20ligue=20disponible=20parce=20qu'elle=20a=20ses=20valeurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Numéroté 98 : le 97 est pris par le correctif CI mergé juste avant. A. Les valeurs d'Argent saisies et enregistrées en admin n'étaient jamais regardées par le référentiel public. Trois choses les ignoraient, et toutes nommaient une ligue en dur : - `levelUpTroopsAt` s'ouvrait sur `if (league === "silver") return null` - `confirmedLevelUpLeagues`, une liste statique de 5 noms - le message public listait ces 5 noms dans son texte traduit La disponibilité vient maintenant des données : `hasLevelUpTroopsFormula` répond oui quand coefficient ET ratio sont présents et > 0, pour n'importe quelle ligue. Une ligue renseignée s'affiche, une ligue vidée redevient non confirmée, sans aucun nom de ligue dans la logique — un test le vérifie sur le source lui-même (hors commentaires). Le message nomme désormais les ligues réellement disponibles, assemblées par `Intl.ListFormat` pour que le « A, B et C » reste correct dans les 5 langues. Formulation retouchée en conséquence dans les 5 fichiers de messages, avec une branche ICU pour le cas où aucune ligue n'en a. A-bis (trouvé en route, non signalé au brief). La route PUT refusait tout payload contenant un zéro, donc le référentiel Progression était insauvegardable tant qu'une ligue restait vierge — sur une installation neuve, c'est Argent, et la toute première sauvegarde revenait en 400. Or c'est précisément {0, 0} qui marque une ligue non confirmée. La règle devient : une paire est soit renseignée (les deux > 0), soit vierge (les deux à 0) ; à moitié remplie, négative ou NaN, elle reste refusée. B. Le « (Formule de troupes non confirmée) » de l'éditeur admin suit maintenant ce qui est réellement stocké : il disparaît dès que la ligue a ses deux valeurs, et apparaît sur n'importe quelle autre ligue laissée vide — il n'était pas propre à Argent. C. Les ligues sont listées dans l'ordre de progression du jeu (Bronze → Légende, l'ordre de la constante `leagues`), côté admin comme public. Le public l'était déjà ; l'admin affichait Argent en dernier, après Légende, parce que sa ligne était écrite à la main à la suite des cinq « confirmées ». Tests : 21 unitaires (prédicat, validation de sauvegarde, ordre, absence de nom de ligue dans le source, note admin conditionnelle) et 1 e2e qui refait le parcours signalé — constat de l'indisponibilité, saisie côté admin, puis table d'Argent affichée en public avec la valeur calculée depuis les données enregistrées, et retour à l'état vierge. Contre-vérifié en cassant le correctif : le littéral silver remis en place rougit 3 unitaires et l'e2e ; la note admin rendue inconditionnelle en rougit 4. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HJgsDSfsCbbn8cochFGwe2 --- e2e/phase-one.spec.ts | 57 +++++++- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/tr.json | 2 +- .../admin/guides/references/level-up/route.ts | 15 +- src/components/level-up-reference.test.tsx | 72 +++++++++- src/components/level-up-reference.tsx | 24 +++- .../named-parameters-editor.test.tsx | 74 +++++++++- src/components/named-parameters-editor.tsx | 65 +++------ src/lib/level-up.test.ts | 133 +++++++++++++++++- src/lib/level-up.ts | 75 ++++++++-- 13 files changed, 439 insertions(+), 86 deletions(-) diff --git a/e2e/phase-one.spec.ts b/e2e/phase-one.spec.ts index 9b2eba29..702a674f 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 6e0320fc..59c0bec8 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 0233ddd9..647f8faf 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 d2924f52..2b5f2fed 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 87601be5..5281cdbd 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 6f19403b..dc9207cf 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 fd1a015d..83bc085d 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 a051aaa4..5e3fa88e 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 bff5ed6c..843f99f1 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 3079e88e..6e34ff89 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 c8d3d8d6..2556c2c8 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 5f606bdc..606551ce 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 e627c1ac..8e8c1c47 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;