diff --git a/apps/game/src/components/home/play-poster.tsx b/apps/game/src/components/home/play-poster.tsx index d14705e..19b93cc 100644 --- a/apps/game/src/components/home/play-poster.tsx +++ b/apps/game/src/components/home/play-poster.tsx @@ -9,6 +9,7 @@ import { Reveal } from "@/components/ui/reveal" import { useIsDesktop } from "@/hooks/use-is-desktop" import { COMPUTER_GAME, usePlayActions } from "@/hooks/use-play-actions" import { BOT_LEVELS } from "@/i18n/bots" +import { DEFAULT_LANGUAGE } from "@/i18n/locale-path" /** * The front door for anyone without an account: what the game is on one side, @@ -40,7 +41,7 @@ const TEXT_LINK_CLASS = "inline-flex min-h-11 min-w-11 items-center justify-center rounded-sm px-1.5 lg:px-2.5 text-brand-lighter underline underline-offset-[3px] transition-colors duration-200 ease-out hover:text-brand-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand" export function PlayPoster() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const { playOnline, playOffline } = usePlayActions() //the eight faces are eight pictures, and a phone never shows them: mounted //behind `hidden` they are eight requests spent on nothing @@ -109,6 +110,14 @@ export function PlayPoster() { two. One label per link rather than a short and a long one swapped by width: both would be in the served text. */}

+ {i18n.language === DEFAULT_LANGUAGE && ( + <> + + {t("common:home.how_to_play_link")} + + + + )} {t("common:nav.rules")} diff --git a/apps/game/src/components/manual/related-guides.tsx b/apps/game/src/components/manual/related-guides.tsx index 0d935a8..095e241 100644 --- a/apps/game/src/components/manual/related-guides.tsx +++ b/apps/game/src/components/manual/related-guides.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next" /** The guides, in reading order, each named by its page's own heading. */ const GUIDES = [ ["/rules", "game:rules.heading"], + ["/how-to-play", "game:howto.heading"], ["/strategy", "game:strategy.heading"], ["/opening-moves", "game:openings.heading"], ["/starting-positions", "game:positions.heading"], diff --git a/apps/game/src/i18n/locales/en/common.json b/apps/game/src/i18n/locales/en/common.json index 54011aa..67f9584 100644 --- a/apps/game/src/i18n/locales/en/common.json +++ b/apps/game/src/i18n/locales/en/common.json @@ -57,7 +57,8 @@ } } }, - "pause_demo": "Pause demo" + "pause_demo": "Pause demo", + "how_to_play_link": "How to play" }, "auth": { "sign_in": "Sign in", diff --git a/apps/game/src/i18n/locales/en/game.json b/apps/game/src/i18n/locales/en/game.json index e5577ae..fa3f3e7 100644 --- a/apps/game/src/i18n/locales/en/game.json +++ b/apps/game/src/i18n/locales/en/game.json @@ -516,5 +516,89 @@ "done": "Got it", "skip": "Skip" }, - "related_guides": "Related guides" + "related_guides": "Related guides", + "howto": { + "title": "How to play", + "heading": "How to play Abalone", + "intro": "To play Abalone, take turns with your opponent and move one, two or three of your marbles one space. A longer line of your marbles can push a shorter line of theirs, and the first player to push six of their marbles off the board wins. Below are the rules in five steps and a first game you can play now, free against the computer. For the full detail, see the Abalone rules.", + "steps": { + "title": "The rules in five steps", + "setup": { + "title": "Start with 14 marbles each", + "body": "Each player has 14 marbles on a hexagon board. Black moves first, then you take turns. You cannot pass." + }, + "move": { + "title": "Move up to three marbles", + "body": "On your turn, move one, two or three of your own marbles one space to a neighbouring space. Two or three marbles must sit in a straight line and move together." + }, + "kinds": { + "title": "Choose an in-line or a side-step move", + "body": "An in-line move sends the group along the line it points. A side step slides the group sideways, and every space it moves into must be empty." + }, + "push": { + "title": "Push with more marbles", + "body": "Move in line into your opponent's marbles to push them, if you have more marbles in the line than they do: 2 push 1, 3 push 1 and 3 push 2. The space behind them must be empty or off the board. Equal numbers never push." + }, + "win": { + "title": "Push six marbles off to win", + "body": "A marble pushed past the edge is out of the game. The first player to push six of the opponent's marbles off wins." + } + }, + "first_game": { + "title": "Your first game, move by move", + "intro": "Press the button below to open a game against the computer. This is how the first minutes go.", + "steps": { + "open": { + "title": "Open the game", + "body": "Press Play against the computer. The board opens ready to play, with no account and nothing to download." + }, + "pick": { + "title": "Pick your marbles", + "body": "On your turn, tap up to three of your marbles that sit in a line." + }, + "direction": { + "title": "Choose where they go", + "body": "Tap one of the highlighted spaces. Your marbles move there and the computer replies." + }, + "stuck": { + "title": "Ask for help when you are stuck", + "body": "The Hint button shows a suggested move, and Undo takes back your last move." + }, + "push": { + "title": "Look for your first push", + "body": "Once the armies touch, line up more marbles than your opponent has in a row and push. Keep your own marbles together so they cannot do the same to you." + } + } + }, + "faq": { + "title": "Questions beginners ask", + "items": { + "start": { + "question": "How do you start a game of Abalone?", + "answer": "Each player puts 14 marbles on their side of the board. Black goes first, then you take turns, one move each." + }, + "first_move": { + "question": "What is a good first move in Abalone?", + "answer": "Move a short row from the front toward the center and keep your marbles touching. Do not send one marble alone ahead of the rest." + }, + "win": { + "question": "How do you win at Abalone?", + "answer": "Push six of your opponent's marbles off the edge of the board. Trapping marbles does not win the game." + }, + "free": { + "question": "Can I learn to play Abalone online for free?", + "answer": "Yes. Play against the computer or share one screen with a friend in your browser, with no account and no download." + } + } + }, + "try": { + "title": "Play your first game", + "more_reading": "Done with the basics? Read the opening moves for beginners and the strategy tips." + }, + "play": { + "cta": "Play against the computer", + "more": "Other ways to play" + }, + "rules_link": "New to the game? Follow how to play Abalone step by step." + } } diff --git a/apps/game/src/routing/config.ts b/apps/game/src/routing/config.ts index c0943f2..794cd01 100644 --- a/apps/game/src/routing/config.ts +++ b/apps/game/src/routing/config.ts @@ -26,6 +26,7 @@ export const routes = rootRoute([ layout("subpage", "layouts/subpage.layout.tsx", [ route("/rules", "pages/rules.page.tsx"), route("/strategy", "pages/strategy.page.tsx"), + route("/how-to-play", "pages/how-to-play.page.tsx"), route("/opening-moves", "pages/opening-moves.page.tsx"), route("/play-online", "pages/play-online.page.tsx"), route("/starting-positions", "pages/starting-positions.page.tsx"), diff --git a/apps/game/src/routing/pages/how-to-play.page.tsx b/apps/game/src/routing/pages/how-to-play.page.tsx new file mode 100644 index 0000000..37f4f89 --- /dev/null +++ b/apps/game/src/routing/pages/how-to-play.page.tsx @@ -0,0 +1,142 @@ +import { createFileRoute, Link } from "@tanstack/react-router" +import { Trans, useTranslation } from "react-i18next" +import type { ManualChapter } from "@/components/manual/chapter" +import { Chapter } from "@/components/manual/chapter" +import { FaqList } from "@/components/manual/faq-list" +import { ManualHero, ManualPage } from "@/components/manual/manual-page" +import { RelatedGuides } from "@/components/manual/related-guides" +import { PlayBlock } from "@/components/play-block" +import { SubpageHeader } from "@/components/ui/subpage-header" +import { COMPUTER_GAME } from "@/hooks/use-play-actions" +import { routeLanguage } from "@/routing/route-language" +import { seoPageHead } from "@/routing/seo-pages" + +export const Route = createFileRoute("/_subpage/how-to-play")({ + head: ({ match }) => + seoPageHead("/how-to-play", routeLanguage(match.context)), + component: HowToPlayPage, +}) + +/** + * How to play, for somebody who has never played: the rules in five steps, a + * first game against the computer, and a few beginner questions. Built like the + * other guides, with link targets for each step (/how-to-play#push) that the + * HowTo markup points at (`structured-data.ts`). + */ + +/** The rule steps, in the order the HowTo markup lists them. */ +const RULE_STEPS = ["setup", "move", "kinds", "push", "win"] as const + +const GAME_STEPS = ["open", "pick", "direction", "stuck", "push"] as const + +const FAQ_ITEMS = ["start", "first_move", "win", "free"] as const + +const TEXT_LINK_CLASS = + "font-semibold text-brand-light underline underline-offset-2 hover:text-white" + +const STEP_LIST_CLASS = + "flex list-decimal flex-col gap-y-3 pl-6 marker:font-semibold marker:text-faint" + +function HowToPlayPage() { + const { t } = useTranslation() + + const chapters: ManualChapter[] = [ + { id: "rules-in-five-steps", title: t("game:howto.steps.title") }, + { id: "first-game", title: t("game:howto.first_game.title") }, + { id: "faq", title: t("game:howto.faq.title") }, + { id: "play", title: t("game:howto.try.title") }, + ] + + const playBlock = ( + + ) + + return ( + <> + {/* The page's own heading is on a phone too, so the bar's title is a + label: one `

` on the page. */} + + + + ), + rules: , + }} + /> + } + actions={playBlock} + /> + } + > + +
    + {RULE_STEPS.map((step) => ( +
  1. + + {t(`game:howto.steps.${step}.title`)}. + {" "} + {t(`game:howto.steps.${step}.body`)} +
  2. + ))} +
+
+ + +

{t("game:howto.first_game.intro")}

+
    + {GAME_STEPS.map((step) => ( +
  1. + + {t(`game:howto.first_game.steps.${step}.title`)}. + {" "} + {t(`game:howto.first_game.steps.${step}.body`)} +
  2. + ))} +
+
+ + + ({ + id: item, + question: t(`game:howto.faq.items.${item}.question`), + answer: t(`game:howto.faq.items.${item}.answer`), + }))} + /> + + + + {playBlock} +

+ + ), + strategy: ( + + ), + }} + /> +

+
+ + +
+ + ) +} diff --git a/apps/game/src/routing/pages/rules.page.tsx b/apps/game/src/routing/pages/rules.page.tsx index f3fb45f..f04fdb8 100644 --- a/apps/game/src/routing/pages/rules.page.tsx +++ b/apps/game/src/routing/pages/rules.page.tsx @@ -16,6 +16,7 @@ import { RuleDiagram } from "@/components/rule-diagram" import { SubpageHeader } from "@/components/ui/subpage-header" import { useMarbleDesign } from "@/hooks/use-marble-design" import { COMPUTER_GAME } from "@/hooks/use-play-actions" +import { DEFAULT_LANGUAGE } from "@/i18n/locale-path" import type { Diagram } from "@/render/draw-diagram" import { START } from "@/render/start-diagram" import { routeLanguage } from "@/routing/route-language" @@ -209,7 +210,7 @@ const FAQ_ITEMS = [ ] as const function RulesPage() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() // The diagrams are the player's own marbles, not a stock illustration. const [marbleDesign] = useMarbleDesign() @@ -471,6 +472,18 @@ function RulesPage() { ), }))} /> + {i18n.language === DEFAULT_LANGUAGE && ( +

+ + ), + }} + /> +

+ )}

= "/rules": ["game", "rules"], "/strategy": ["game", "strategy"], "/offline": ["game", "offline"], + "/how-to-play": ["game", "howto"], "/opening-moves": ["game", "openings"], "/starting-positions": ["game", "positions"], "/play-online": ["game", "playOnline"], diff --git a/apps/game/src/routing/seo-pages.test.ts b/apps/game/src/routing/seo-pages.test.ts index 9a29ab7..d4bd1cf 100644 --- a/apps/game/src/routing/seo-pages.test.ts +++ b/apps/game/src/routing/seo-pages.test.ts @@ -21,6 +21,11 @@ const EXPECTED_COPY = { description: "Seven Abalone strategy tips: control the center, keep groups of three, pick a safe opening and push at the right moment. Try them against the computer.", }, + "/how-to-play": { + title: "How to play Abalone: rules in 5 steps and a first game", + description: + "How to play Abalone: the rules in five plain steps, a first game move by move and how to win. Then play the computer free, no account needed.", + }, "/opening-moves": { title: "Abalone opening moves: a guide for beginners", description: @@ -87,6 +92,7 @@ describe("SITEMAP", () => { expect(Object.keys(SITEMAP).sort()).toEqual( [ "/", + "/how-to-play", "/opening-moves", "/play-online", "/rules", @@ -104,6 +110,7 @@ describe("SITEMAP", () => { "/rules": "2026-11-02", "/strategy": "2026-11-02", "/play-online": "2026-11-01", + "/how-to-play": "2026-11-02", "/opening-moves": "2026-11-02", "/starting-positions": "2026-11-02", }) diff --git a/apps/game/src/routing/seo-pages.ts b/apps/game/src/routing/seo-pages.ts index 396399d..59b2ff9 100644 --- a/apps/game/src/routing/seo-pages.ts +++ b/apps/game/src/routing/seo-pages.ts @@ -34,6 +34,7 @@ export const SEO_PAGES: Record< | "/" | "/rules" | "/strategy" + | "/how-to-play" | "/opening-moves" | "/starting-positions" | "/offline" @@ -58,6 +59,12 @@ export const SEO_PAGES: Record< "Seven Abalone strategy tips: control the center, keep groups of three, pick a safe opening and push at the right moment. Try them against the computer.", image: "rules", }, + "/how-to-play": { + title: "How to play Abalone: rules in 5 steps and a first game", + description: + "How to play Abalone: the rules in five plain steps, a first game move by move and how to win. Then play the computer free, no account needed.", + image: "rules", + }, "/opening-moves": { title: "Abalone opening moves: a guide for beginners", description: @@ -102,6 +109,7 @@ export const SITEMAP: Record< "/rules": "2026-11-02", "/strategy": "2026-11-02", "/play-online": "2026-11-01", + "/how-to-play": "2026-11-02", "/opening-moves": "2026-11-02", "/starting-positions": "2026-11-02", } @@ -117,6 +125,7 @@ export const PAGE_LANGUAGES: Record = { "/": SUPPORTED_LANGUAGES, "/rules": SUPPORTED_LANGUAGES, "/strategy": SUPPORTED_LANGUAGES, + "/how-to-play": ["en"], "/opening-moves": ["en"], "/starting-positions": ["en"], "/offline": SUPPORTED_LANGUAGES, diff --git a/apps/game/src/routing/structured-data.test.ts b/apps/game/src/routing/structured-data.test.ts index 5c50b85..bda8f47 100644 --- a/apps/game/src/routing/structured-data.test.ts +++ b/apps/game/src/routing/structured-data.test.ts @@ -253,10 +253,29 @@ describe("structured data", () => { }) }) + it("describes /how-to-play as a HowTo of its five steps plus a FAQ", () => { + const [howTo, faq] = structuredData("/how-to-play") + + expect(howTo["@type"]).toBe("HowTo") + expect(howTo.url).toBe(`${ORIGIN}/how-to-play`) + const steps = howTo.step as Json[] + expect(steps.map((step) => step.url)).toEqual( + ["setup", "move", "kinds", "push", "win"].map( + (id) => `${ORIGIN}/how-to-play#${id}`, + ), + ) + for (const step of steps) { + expect(step.text as string).not.toMatch(/<[^>]+>/) + } + expect(faq["@type"]).toBe("FAQPage") + expect(faq.mainEntity).toHaveLength(4) + }) + describe("breadcrumb trail", () => { const GUIDES = [ "/rules", "/strategy", + "/how-to-play", "/opening-moves", "/starting-positions", ] as const @@ -289,6 +308,7 @@ describe("structured data", () => { expect(names).toEqual([ "Rules", "Strategy", + "How to play", "Opening moves", "Starting positions", ]) diff --git a/apps/game/src/routing/structured-data.ts b/apps/game/src/routing/structured-data.ts index 2682c7f..80314f4 100644 --- a/apps/game/src/routing/structured-data.ts +++ b/apps/game/src/routing/structured-data.ts @@ -103,6 +103,26 @@ function rulesHowTo(): Json { } } +function howToPlayHowTo(): Json { + return { + "@context": "https://schema.org", + "@type": "HowTo", + name: enGame.howto.heading, + url: siteUrl("/how-to-play"), + step: Object.entries(enGame.howto.steps) + .filter(([key]) => key !== "title") + .map(([key, step]) => { + const { title, body } = step as { title: string; body: string } + return { + "@type": "HowToStep", + name: title, + url: siteUrl(`/how-to-play#${key}`), + text: body, + } + }), + } +} + /** The FAQ items as the page shows them, answers without their link markup. */ function faqPage( faq: Record, @@ -164,6 +184,7 @@ const GUIDE_NAMES: Partial< > = { "/rules": (language) => navLabel("rules", language), "/strategy": (language) => navLabel("strategy", language), + "/how-to-play": () => enGame.howto.title, "/opening-moves": () => enGame.openings.title, "/starting-positions": () => enGame.positions.title, } @@ -244,6 +265,11 @@ export function structuredDataScripts( toScript(faqPage(enGame.strategy.faq.items)), ] } + if (path === "/how-to-play") + return [ + toScript(howToPlayHowTo()), + toScript(faqPage(enGame.howto.faq.items)), + ] if (path === "/opening-moves") { const image = siteUrl(`/images/social/og-${page.image}.jpg`) return [ diff --git a/apps/game/src/sw.ts b/apps/game/src/sw.ts index 198a570..2680c80 100644 --- a/apps/game/src/sw.ts +++ b/apps/game/src/sw.ts @@ -73,6 +73,7 @@ registerInstallRouteWarmer({ "/online", "/rules", "/strategy", + "/how-to-play", "/opening-moves", "/play-online", "/starting-positions", diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index 18f9d56..c42c281 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -71,6 +71,7 @@ for (const path of [ "/", "/rules", "/strategy", + "/how-to-play", "/opening-moves", "/play-online", "/starting-positions", diff --git a/e2e/play-block.spec.ts b/e2e/play-block.spec.ts index 633c752..bb5e5cb 100644 --- a/e2e/play-block.spec.ts +++ b/e2e/play-block.spec.ts @@ -328,13 +328,14 @@ test.describe("layout", () => { .map((link) => link.text), ).toEqual([MORE, MORE]) - //the play online page says how to play a friend; it is no game - //that a reader expects to start + //the play online and how to play pages are guides that say how to + //play; neither is a game that a reader expects to start const promising = links.filter( (link) => /play|game/i.test(link.text) && link.text !== MORE && - link.href !== "/play-online", + link.href !== "/play-online" && + link.href !== "/how-to-play", ) expect(promising.length).toBeGreaterThanOrEqual(3) for (const link of promising) { diff --git a/e2e/related-guides.spec.ts b/e2e/related-guides.spec.ts index 0b5a67c..f1f60bc 100644 --- a/e2e/related-guides.spec.ts +++ b/e2e/related-guides.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test" import { openApp } from "./support/app" -// Each guide ends with links to the other three, so a reader who finished one +// Each guide ends with links to the other guides, so a reader who finished one // finds the next and a crawler reaches every guide from each of them. The links // are in the served HTML, in the page's language (/de/rules links /de/strategy), // and sit below the way to the board, which stays on the first screen. @@ -9,6 +9,7 @@ import { openApp } from "./support/app" const GUIDES = [ "/rules", "/strategy", + "/how-to-play", "/opening-moves", "/starting-positions", ] as const @@ -35,7 +36,7 @@ test.describe("related guides in the served HTML", () => { for (const guide of GUIDES) { const path = `${prefix}${guide}` - test(`${path} links the other three guides, each a 200 page`, async ({ + test(`${path} links the other guides, each a 200 page`, async ({ request, }) => { const response = await request.get(path, { maxRedirects: 0 }) @@ -77,7 +78,9 @@ test.describe("related guides at 375×667", () => { ).toBeLessThanOrEqual(667) const related = page.locator("[data-related-guides]") - await expect(related.getByRole("link")).toHaveCount(3) + await expect(related.getByRole("link")).toHaveCount( + GUIDES.length - 1, + ) const relatedBox = await related.boundingBox() expect(relatedBox?.y ?? 0).toBeGreaterThan(667) }) diff --git a/e2e/seo.spec.ts b/e2e/seo.spec.ts index c34590f..1ef41f9 100644 --- a/e2e/seo.spec.ts +++ b/e2e/seo.spec.ts @@ -15,6 +15,7 @@ const INDEXABLE = { "/rules": "Abalone rules: how to play, moves, sumito and winning", "/offline": "Play Abalone vs the computer or a friend on one device", "/strategy": "Abalone strategy: 7 tips to win more games", + "/how-to-play": "How to play Abalone: rules in 5 steps and a first game", "/opening-moves": "Abalone opening moves: a guide for beginners", "/play-online": "Play Abalone Online With Friends: Invite by Link", "/starting-positions": @@ -29,6 +30,7 @@ const SITEMAP = { "/rules": "2026-11-02", "/strategy": "2026-11-02", "/play-online": "2026-11-01", + "/how-to-play": "2026-11-02", "/opening-moves": "2026-11-02", "/starting-positions": "2026-11-02", } as const @@ -82,6 +84,7 @@ test.describe("indexable pages", () => { "/rules": "Abalone rules: how to play", "/offline": "Play Abalone vs the computer or a friend", "/strategy": "Abalone strategy: 7 tips to win", + "/how-to-play": "How to play Abalone", "/opening-moves": "Abalone opening moves for beginners", "/play-online": "Play Abalone online with a friend", "/starting-positions": "Abalone starting positions", diff --git a/e2e/skip-link.spec.ts b/e2e/skip-link.spec.ts index b50e7c1..d121fb5 100644 --- a/e2e/skip-link.spec.ts +++ b/e2e/skip-link.spec.ts @@ -12,6 +12,7 @@ for (const path of [ "/", "/rules", "/strategy", + "/how-to-play", "/opening-moves", "/offline", "/online",