diff --git a/ab-testing/config/abTests.ts b/ab-testing/config/abTests.ts index 64335553d5f..598e8b73bad 100644 --- a/ab-testing/config/abTests.ts +++ b/ab-testing/config/abTests.ts @@ -144,6 +144,26 @@ const ABTests: ABTest[] = [ groups: ["control", "variant"], shouldForceMetricsCollection: false, }, + /** + * Puzzles & Games rollout, tier v0 (the master switch). + * + * Gates the baseline Puzzles & Games experience: the new Puzzles Hub + * page, and the 6 V0 puzzle pages (sudoku easy/medium/hard/killer, + * word-wheel, wordiply). At v0, there is no archive, no calendar, no + * progress indicators, no sign-in-to-track-progress prompt, no "more + * from puzzles" rail, and the hub's sub-nav has no links yet. + * + * This is the master switch for the whole Puzzles & Games experience: + * turning it off (or down to 0%) hides everything: the hub, the V0 + * puzzle pages, and (by the cumulative design below) every later tier + * too, since v1/v2 only take effect when this is also enabled. + * + * See `puzzles-new-hub-v1`/`puzzles-new-hub-v2` below for the later + * rollout tiers, and `src/lib/puzzlesHubVersionExperiment.ts` / + * `src/lib/puzzlesHubExperiment.ts` in dotcom-rendering for the + * corresponding cumulative gate-check helpers + * (`isPuzzlesHubEnabled`/`isPuzzlesHubV1Enabled`/`isPuzzlesHubV2Enabled`). + */ { name: "puzzles-new-hub", description: "Rollout of the new Puzzles Hub experience", @@ -156,6 +176,68 @@ const ABTests: ABTest[] = [ groups: ["control", "variant"], shouldForceMetricsCollection: false, }, + /** + * Puzzles & Games rollout, tier v1 (w/c 12 Oct launch). + * + * Only takes effect when `puzzles-new-hub` (v0) is ALSO enabled for the + * reader. This test does nothing on its own, by design, so the + * rollout can never end up in an inconsistent state (e.g. v1 features + * showing while the v0 baseline they build on is switched off). + * + * On top of v0, this tier activates: the full hub sub-nav links (to + * /word-games, /logic-puzzles, /trivia-and-quizzes), a + * sign-in-to-track-progress message, a calendar/archive view for + * crosswords/logic-puzzles/word-games (not Wordiply, which has no + * archive), progress indicators (Available/Completed), the "More from + * Puzzles & Games" related-content rail, newsletter signup, and + * changes to the existing crossword page (print CTA repositioning, a + * "play other puzzles" container). + * + * To roll back from v1 to v0 without a deploy: flip this test's + * `audienceSize` to `0 / 100` (or `status` to `"OFF"`) while leaving + * `puzzles-new-hub` untouched. + */ + { + name: "puzzles-new-hub-v1", + description: + "Rollout of the v1 Puzzles & Games features (w/c 12 Oct), on top of the puzzles-new-hub v0 baseline", + owners: ["puzzles.team@guardian.co.uk"], + status: "ON", + expirationDate: "2026-12-31", + type: "server", + audienceSize: 0 / 100, + audienceSpace: "A", + groups: ["control", "variant"], + shouldForceMetricsCollection: false, + }, + /** + * Puzzles & Games rollout, tier v2 (future, no launch date confirmed + * yet as of this writing). + * + * Only takes effect when BOTH `puzzles-new-hub` (v0) AND + * `puzzles-new-hub-v1` are ALSO enabled for the reader, same + * cumulative-by-design principle as v1 above, applied one tier further. + * + * On top of v0+v1, this tier activates: the On the Ball and Film Reveal + * iframe games (Trivia and Quizzes group), a "Most played" container, + * EventKit-driven navigation, migrating existing crossword pages onto + * the new Puzzle Page template, and search-engine mobile app nudges. + * + * Kept at 0% until that work begins; there is nothing to roll back yet. + */ + { + name: "puzzles-new-hub-v2", + description: + "Rollout of the v2 Puzzles & Games features (no date confirmed yet), on top of the puzzles-new-hub/puzzles-new-hub-v1 baseline", + owners: ["puzzles.team@guardian.co.uk"], + status: "ON", + expirationDate: "2026-12-31", + type: "server", + audienceSize: 0 / 100, + audienceSpace: "A", + groups: ["control", "variant"], + shouldForceMetricsCollection: false, + }, { name: "identity-and-trust-consent-rr-banner-us", description: diff --git a/dotcom-rendering/docs/puzzle-page.md b/dotcom-rendering/docs/puzzle-page.md new file mode 100644 index 00000000000..779b77ad361 --- /dev/null +++ b/dotcom-rendering/docs/puzzle-page.md @@ -0,0 +1,716 @@ +# Puzzle Page + +## What is implemented + +Puzzle Page is a single, generic page template for the Guardian's +**iframe-based** puzzles (sudoku, word games, etc.). **dotcom-rendering +(DCR) owns the whole thing on the rendering side**: the `POST /PuzzlePage` +endpoint, the `PuzzleConfig` registry that decides how each puzzle behaves +and renders, and the `PuzzlePageLayout` layout/styling. The `frontend` +(Play/Scala) repo is responsible for fetching/assembling per-instance +content and POSTing it to this endpoint as JSON. See the `frontend` repo's +`docs/puzzle-page.md` (formerly `docs/game-page.md`; its standalone +`GamePageController` no longer exists either, having been merged into +`PuzzlesPageController` there) for the exact JSON payload it sends and how +to wire up a new puzzle from the content-fetching side. + +**Crosswords are explicitly out of scope**, by product decision, and remain +entirely on their existing, separate `/crosswords/*` flow +(`ArticleDesign.Crossword` / `src/layouts/CrosswordLayout.tsx` / the generic +Article pipeline). That flow is unrelated to Puzzle Page and is not +described further in this file. + +Readers reach individual puzzles via `frontend`'s public, top-level URLs, +mirroring how crosswords are already routed, e.g. `/sudoku/easy`, +`/word-wheel`, `/wordiply` (nested only where the puzzle itself has +variants, like sudoku's difficulty levels). This is separate from the +Puzzles Hub (the directory/listing page, unrelated to Puzzle Page), which +stays at `/puzzles-and-games`. None of this is DCR's own routing, it's +`frontend`'s public URL structure, and does not affect DCR's `/PuzzlePage` +endpoint/contract at all; it's mentioned here only so example URLs +elsewhere in this doc stay accurate. + +**Access control lives entirely on the `frontend` side, not here.** DCR's +own `/PuzzlePage` endpoint is, and remains, ungated (see "Hitting it +locally" below). `frontend` gates reader access to these routes via its +existing `PuzzlesHubExperiment`/`puzzles-new-hub` AB test before it ever +POSTs to DCR. DCR does not re-implement or duplicate that gating. + +### The V0 puzzle set + +The `PuzzleConfig` registry (`src/model/puzzles/puzzleConfigs.ts`) currently +contains exactly 6 slugs, all rendered via the generic sandboxed +`PuzzleIframe.island.tsx` component: + +| `slug` | `puzzleGroup` | provider | +| --------------- | --------------- | ---------------------- | +| `sudoku-easy` | `logic-puzzles` | AmuseLabs | +| `sudoku-medium` | `logic-puzzles` | AmuseLabs | +| `sudoku-hard` | `logic-puzzles` | AmuseLabs | +| `sudoku-killer` | `logic-puzzles` | AmuseLabs | +| `word-wheel` | `word-games` | AmuseLabs | +| `wordiply` | `word-games` | bespoke (wordiply.com) | + +Codeword, futoshiki, suguru, and the trivia/quizzes puzzles (on-the-ball, +film-reveal) were removed from the registry for V0 and may return later. +**`iframe` is a discriminated union keyed by `provider` +(`PuzzleProvider = 'amuselabs' | 'wordiply'`), each entry holds only the +minimal, provider-specific identity data needed to build its iframe URL, +not a pre-baked final URL string.** AmuseLabs entries carry a `set` +identifier (e.g. `'guardian-sudoku-easy'`, `'guardian-killer-sudoku-medium'`); +Wordiply carries its `baseUrl`. Building the actual URL, including which +query params a given provider does or doesn't accept, is the job of a +per-provider strategy module, `src/lib/puzzleIframeUrl.ts`, not this +registry: + +- `buildAmuseLabsUrl(config, context)` builds `set`/`embed=1`/`idx=1`, + then conditionally `uid` (only when signed in, confirmed against the + native apps' real AmuseLabs integration) and always `darkMode=0|1` (a + plain literal query value, also confirmed, unlike `uid` this is never + conditionally omitted). +- `buildWordiplyUrl(config, context)` returns `config.baseUrl` unmodified, + deliberately minimal pending confirmation of what query params Wordiply + actually supports (none today). +- `resolvePuzzleIframeUrl(config, context)` dispatches on + `config.iframe.provider` to the right builder, and is the single entry + point callers should use. It is exhaustively type-checked (a `never` + check in the switch's default branch), so adding a new `PuzzleProvider` + to the union without also adding its builder is a compile error, not a + silent runtime gap. + +This replaces an earlier design that blindly applied the same query +params (`uid`, and DCR's own `guardian-puzzle-context` JSON blob) to +every provider regardless of what it actually supports. That was +fragile, and already conceptually wrong even though harmless in practice +with only 2 V0 providers (one of which happens to ignore unknown params). +It also replaces an even earlier design where every AmuseLabs-hosted +entry's URL was built from one shared template, substituting DCR's own +`slug` in for AmuseLabs' `set=guardian-*` query param, a real, live bug: +nothing guarantees a provider's own naming convention matches our +internal slug, and it already silently didn't for `sudoku-killer` (its +real, confirmed AmuseLabs `set` is `guardian-killer-sudoku-medium`, not +`guardian-sudoku-killer`, a different word order plus an unexplained +"-medium" suffix that is genuinely part of the real, working identifier). +Every AmuseLabs entry's `set` is now written out independently in the +registry, confirmed against the native Android/iOS apps' own real, +working AmuseLabs integration, so a future change to one entry can never +silently or accidentally affect another. + +`guardian-puzzle-context` (DCR's own JSON-encoded context blob, unrelated +to any one provider) deliberately stays **outside** this per-provider +strategy: it is applied uniformly to every provider regardless of +`iframe.provider`, by `buildPuzzleIframeSrc` in +`PuzzleIframe.island.tsx`, layered on top of whatever +`resolvePuzzleIframeUrl` already resolved. This is DCR's own additional +channel, not a provider-specific mechanism, providers that don't +understand it simply ignore it, so it is correctly generic where `uid`/ +`darkMode` are correctly provider-specific. + +### Hitting it locally + +Start the dev server (from the `dotcom-rendering` sub-directory): + +``` +make dev +``` + +This starts webpack-dev-server on `http://localhost:3030` +(`webpack/webpack.config.dev-server.js`). + +There is currently **no AB gate** on this route. +`src/server/handler.puzzlePage.web.ts` validates the body +(`validateAsPuzzlePageType`), looks up the `PuzzleConfig` for the request's +`slug` (`404` if unknown), and renders unconditionally otherwise, with no +`serverSideABTests`/participation check of any kind (see "Open questions" +below for the AB-gate/kill-switch situation). + +Generate fixture JSON for all 6 slugs using the `tsx` devDependency (no +extra install needed) and `fixtures/manual/puzzlePage.ts`'s +`createPuzzlePage`/`puzzlePageFixtures`: + +``` +cat > /tmp/dump-puzzle-fixtures.ts <<'EOF' +import * as fs from 'fs'; +import { puzzlePageFixtures } from './fixtures/manual/puzzlePage'; + +fs.mkdirSync('/tmp/puzzle-fixtures', { recursive: true }); +for (const [slug, page] of Object.entries(puzzlePageFixtures)) { + fs.writeFileSync(`/tmp/puzzle-fixtures/${slug}.json`, JSON.stringify(page, null, 2)); +} +console.log('wrote', Object.keys(puzzlePageFixtures).length, 'fixtures to /tmp/puzzle-fixtures'); +EOF +pnpm exec tsx /tmp/dump-puzzle-fixtures.ts +``` + +Then hit the route directly. **This is DCR's own local `POST` endpoint, not +a real, browsable end-user URL.** `/PuzzlePage` only accepts `POST` +requests with a JSON body; DCR is not directly browsable by real users +without `frontend` in front of it constructing and sending that body. + +| `slug` | local command | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `sudoku-easy` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-easy.json` | +| `sudoku-medium` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-medium.json` | +| `sudoku-hard` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-hard.json` | +| `sudoku-killer` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/sudoku-killer.json` | +| `word-wheel` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/word-wheel.json` | +| `wordiply` | `curl -i -X POST http://localhost:3030/PuzzlePage -H "Content-Type: application/json" --data @/tmp/puzzle-fixtures/wordiply.json` | + +All six should return `200`, hydrating into a sandboxed iframe pointed at +that slug's resolved provider URL. An unknown slug (or a known slug renamed +to something else in the request body) returns `404`. + +### How to configure/add a new puzzle + +Both steps are config-only. The layout does not need any changes for a new +iframe-based slug already using a supported provider: + +1. Add a new key to `src/model/puzzles/puzzleConfigs.ts`'s `puzzleConfigs` + record (`slug`, `puzzleGroup`, `iframe`, `shareEnabled`, `printEnabled`, + `hasArchive`, `title`, `description`, optional `image`). + **`printEnabled` is currently `true` only for the 4 sudoku entries, by + explicit product decision (PR #16700 review): existing readers rely on + printing to play Sudoku, since there isn't really another way to do + that on the web, and the current static Sudoku page is being retired + once V0 ships. No other puzzle currently needs print.** Don't default a + new entry to `printEnabled: true` without a similarly explicit product + reason, see `PuzzleConfig.printEnabled`'s doc comment. + What `iframe` + needs depends on the provider: + - **Another AmuseLabs-hosted puzzle** (the common case): reuse the + `amuseLabsPuzzle(slug, puzzleGroup, title, description, set, printEnabled)` + helper, supplying that puzzle's own confirmed AmuseLabs `set` + identifier and whether it should show the print button (see above). + `buildAmuseLabsUrl` (`src/lib/puzzleIframeUrl.ts`) is reused + automatically, no new builder needed. **`set` must be confirmed + against the actual provider (or a source that has itself confirmed + it, e.g. the native apps' own working integration), not derived + from `slug` or copied from another entry.** Never assume AmuseLabs' + own naming convention matches our internal slug: the killer-sudoku + incident is a direct, confirmed example of that assumption silently + being wrong. + - **A genuinely new provider** (e.g. MovieGrid, returning in a future + version, not in the V0 registry today): needs its own + `PuzzleIframeConfig` union variant, its own builder function in + `src/lib/puzzleIframeUrl.ts`, and a new case in + `resolvePuzzleIframeUrl`'s dispatcher (the `never` exhaustiveness + check will fail to compile until this is done). This is a new, + additive strategy, not a blind copy-paste of `buildAmuseLabsUrl`'s + logic, a new provider's confirmed query param support may differ + (see the iframe URL strategy description in "The V0 puzzle set" + above). + `validatePuzzleConfigs` runs once at module load and throws + immediately if the entry is malformed (mismatched `slug`, unknown + `puzzleGroup`, an invalid provider-specific `iframe` config, empty + `title`/`description`, or a present-but-empty `image`). + **Write real, curated copy for `title`/`description`**, sourced from + the product team's SEO spreadsheet for that puzzle (see "SEO" below for + the exact `{date}` templating mechanism). It becomes the page's + `