diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index c5d4b40d..0a1920b8 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -16,9 +16,9 @@
|---|---|---|---|---|---|---|
| **P0** | 26 | 6 | 3 | 0 | 0 | 35 |
| **P1** | 13 | 3 | 2 | 0 | 1 | 19 |
-| **P2** | 9 | 6 | 0 | 8 | 0 | 23 |
+| **P2** | 9 | 6 | 0 | 9 | 0 | 24 |
| **P3 (oos)** | 0 | 0 | 0 | 0 | 17 | 17 |
-| **total** | **48** | **15** | **5** | **8** | **18** | **94** |
+| **total** | **48** | **15** | **5** | **9** | **18** | **95** |
@@ -30,8 +30,8 @@
| [warcouncil](../team-leads/warcouncil.md) | 6 |
| [wireguard](../team-leads/wireguard.md) | 4 |
| [shipwright](../team-leads/shipwright.md) | 3 |
+| [tourguide](../team-leads/tourguide.md) | 3 |
| [testwright](../team-leads/testwright.md) | 2 |
-| [tourguide](../team-leads/tourguide.md) | 2 |
| [asset-audio](../team-leads/asset-audio.md) | 1 |
|
@@ -126,6 +126,7 @@
| [p2-26](p2-26-mundane-wonder-sprites.md) | ❌ missing | Mundane-wonder sprites — 24 distinct, higher-fidelity art | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 |
| [p2-27](p2-27-city-population-tier-sprites.md) | ❌ missing | City population-tier sprites — city_q1 through city_q5 | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 |
| [p2-28](p2-28-sprite-provenance-ledger.md) | ❌ missing | Sprite provenance ledger — LICENSES.md per-file attribution | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 |
+| [p2-29](p2-29-guide-welcome-homepage-theme-alignment.md) | ❌ missing | Welcome modal + HomePage lore + guide theme align to the player's chosen race/gender | [tourguide](../team-leads/tourguide.md) | 2026-04-17 |
## Out of Scope (Game 2 / Game 3)
diff --git a/.project/objectives/p1-15-guide-next-deploy-infra.md b/.project/objectives/p1-15-guide-next-deploy-infra.md
index 89bbccfe..f9ed4691 100644
--- a/.project/objectives/p1-15-guide-next-deploy-infra.md
+++ b/.project/objectives/p1-15-guide-next-deploy-infra.md
@@ -46,6 +46,14 @@ All acceptance bullets verifiable:
fresh deploy is picked up on the next visit; (f) passes through
`/__sim-cache/` for future p2-21 baked frames. `docker exec
host-nginx nginx -t` validates before the reload.
+ **Gotcha (2026-04-17):** the outer `http {}` block must contain
+ `include /etc/nginx/mime.types; default_type application/octet-stream;`.
+ Without it nginx serves every file as `text/plain`, which browsers
+ refuse to execute as ES modules — the guide renders blank with
+ "disallowed MIME type" console errors on every `/assets/*.js`.
+ `cmd_deploy_guide_next`'s step-5 probe now asserts
+ `Content-Type: application/javascript` on a hashed JS asset to
+ catch this regression at deploy time.
- ✓ **Bind mount** — `/bigdisk/nginx/docker-compose.yml` adds
`/bigdisk/next/:/var/www/next/:ro`. Container recreated via
`docker compose up -d`; `docker exec host-nginx ls /var/www/next/mc/`
diff --git a/.project/objectives/p2-29-guide-welcome-homepage-theme-alignment.md b/.project/objectives/p2-29-guide-welcome-homepage-theme-alignment.md
new file mode 100644
index 00000000..d9b592ff
--- /dev/null
+++ b/.project/objectives/p2-29-guide-welcome-homepage-theme-alignment.md
@@ -0,0 +1,93 @@
+---
+id: p2-29
+title: Welcome modal + HomePage lore + guide theme align to the player's chosen race/gender
+priority: p2
+status: missing
+scope: game1
+owner: tourguide
+updated_at: 2026-04-17
+evidence:
+ - public/games/age-of-dwarves/guide/src/components/welcome/WelcomeModal.tsx
+ - public/games/age-of-dwarves/guide/src/pages/HomePage.tsx
+ - public/games/age-of-dwarves/guide/src/theme/fantasy-theme.ts
+ - src/packages/guide/src/theme/RaceThemeProvider.tsx
+ - public/games/age-of-dwarves/guide/src/contexts/PreferencesContext.tsx
+---
+
+## Summary
+
+The guide already exposes a welcome modal that lets the player pick a race
+(Dwarf in Game 1 — `CONCRETE_RACES = ['dwarf']`) and a gender, plus a
+`RaceThemeProvider` that merges a per-race/per-gender palette into the
+styled-components theme. But the three surfaces don't line up:
+
+- **WelcomeModal copy** reads like a settings dialog ("Settings", field
+ labels) rather than an invitation into the story, so the player's first
+ impression is admin-UI-shaped.
+- **HomePage ``** *does* pull the player's race + name via
+ `usePreferences()`, but the surrounding `` / `` / ``
+ hardcode out-of-scope narrative ("16 asymmetric races, 5 magic schools")
+ that is Game 2/3 territory and does NOT update when the player picks a
+ dwarf leader.
+- **Theme application** — the palette change from `RaceThemeProvider` fires
+ on confirm, but a browser already on the HomePage may not re-derive the
+ Hero/Pitch colors in a visually coherent way (Cinzel serif, Dwarf copper
+ `#c07040`, etc.). The "align with welcome" contract is not exercised.
+
+When the player picks **Dwarf + Female** in the modal and clicks Begin,
+all three surfaces should read as one piece: a dwarf-themed guide,
+referring to the named dwarf leader, in Dwarf scope language (no "5
+magic schools" pitch, no generic cross-race framing).
+
+## Acceptance
+
+- **Race-aware Hero / Tagline / Pitch**: the top-of-HomePage block no
+ longer hardcodes cross-race copy. For Game 1 Dwarf scope, the pitch is
+ Dwarf-first (craft, industry, mundane tech, fortress cities). When a
+ future episode adds a second concrete race, the prose comes from a
+ race-keyed data source (e.g. `resources/races//guide_pitch.md`)
+ — not another `{race.name}` sprinkle.
+- **"Your story begins" block** renders race-specific lore for the
+ chosen race + gender. Dwarf-female reads coherently ("Your band of
+ free Dwarves have chosen you, , as their leader" in a voice
+ consistent with the Dwarf palette). The current ley-line /
+ magic-schools paragraphs are gated behind ``
+ (Game 2 content) so a default Game 1 bundle shows Dwarf narrative
+ only; `VITE_DEV_GUIDE=1` keeps them visible per p1-15.
+- **Theme alignment**: after confirming Dwarf + Female in the
+ WelcomeModal, the `RaceThemeProvider` palette is live —
+ `theme.colors.primary.main === '#c07040'` (dwarf copper),
+ `theme.typography.fontFamily.serif === "Cinzel, …"`,
+ `theme.colors.background.primary === '#1a1510'`. HomePage's
+ `` / `` / `` accents derive from
+ those tokens and visibly shift from the default palette to the Dwarf
+ palette within one render cycle.
+- **WelcomeModal voice**: the modal's copy is rewritten in the same
+ voice as HomePage. Instead of "Settings", the modal frames the
+ choice as the start of the story ("Choose your leader" / "Your band
+ of free Dwarves choose…" etc.). `BeginButton` label reinforces the
+ story beat rather than "Save".
+- **Smoke test**: `pnpm --prefix public/games/age-of-dwarves/guide
+ test:e2e --grep welcome` (new spec) walks (1) load page fresh, (2)
+ pick Dwarf + Female + type a name, (3) click Begin, (4) assert the
+ HomePage `` reads "Your Story Begins" with the chosen
+ name rendered in `` + the body prose uses Dwarf
+ vocabulary (no "magic schools" / "archons" strings). Console
+ remains zero-error.
+
+## Non-goals
+
+- Additional races beyond Dwarf — Game 2/3 race content stays gated.
+- Multi-language / i18n for the prose — copy is authored in English,
+ matching the rest of the guide.
+- In-modal preview of HomePage changes — `onPreview` already flashes
+ the palette; no need for a live HomePage inset.
+- A new WelcomeModal design — this is a copy + theme-alignment pass,
+ not a visual redesign.
+
+## Why this belongs to tourguide
+
+Closes the "clone → click Begin → first-page impression" path that the
+dev-guide deploy (`mc.next.black.local`, p1-15) is meant to showcase.
+Same owner that authored the tabbed Progress Report + the Markdown
+component + the deploy pipeline.
diff --git a/.project/team-leads/README.md b/.project/team-leads/README.md
index eace7320..3559a827 100644
--- a/.project/team-leads/README.md
+++ b/.project/team-leads/README.md
@@ -69,4 +69,4 @@ specialist does not own any objective.
| [warcouncil](warcouncil.md) | Warcouncil | AI action generation, MCTS, GPU look-ahead, clan personality differentiation | p0-01, p0-02, p0-20 |
| [shipwright](shipwright.md) | Shipwright | Drive Game 1 to release via /experts-team cron loop until every P0 is done | p0-05, p0-14, p0-15, p0-16, p0-17 |
| [testwright](testwright.md) | Testwright | Regression-test coverage across Rust + GDScript + data validators — seeds the evidence substrate for the Objective Status Integrity rule | p1-09, p2-10 |
-| [tourguide](tourguide.md) | Tourguide | Developer experience of the guide web app — dev server boots on plum, route coverage e2e, dev-tier deploy at mc.next.black.local, sim-cache baked on apricot, and the "no build output in src" rule stays enforced | p1-11, p1-12, p1-13, p1-15, p1-17, p2-20, p2-21 |
+| [tourguide](tourguide.md) | Tourguide | Developer experience of the guide web app — dev server boots on plum, route coverage e2e, dev-tier deploy at mc.next.black.local, sim-cache baked on apricot, welcome→HomePage→theme alignment, and the "no build output in src" rule stays enforced | p1-11, p1-12, p1-13, p1-15, p1-17, p2-20, p2-21, p2-29 |
diff --git a/.project/team-leads/tourguide.md b/.project/team-leads/tourguide.md
index edc31706..e8770abd 100644
--- a/.project/team-leads/tourguide.md
+++ b/.project/team-leads/tourguide.md
@@ -10,6 +10,7 @@ objectives:
- p1-17
- p2-20
- p2-21
+ - p2-29
---
## Mandate
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index 72140371..52063a56 100644
--- a/public/games/age-of-dwarves/data/objectives.json
+++ b/public/games/age-of-dwarves/data/objectives.json
@@ -1,12 +1,12 @@
{
- "generated_at": "2026-04-18T06:48:23Z",
+ "generated_at": "2026-04-18T06:53:01Z",
"totals": {
"stub": 5,
"partial": 15,
- "missing": 8,
- "done": 48,
"oos": 18,
- "total": 94
+ "missing": 9,
+ "done": 48,
+ "total": 95
},
"objectives": [
{
@@ -779,6 +779,16 @@
"updated_at": "2026-04-17",
"summary": "Every sprite PNG that ships in `public/games/age-of-dwarves/assets/sprites/` must have a corresponding row in `public/games/age-of-dwarves/assets/sprites/LICENSES.md` recording source, license, author, URL, and SHA256. This is a cross-cutting compliance objective that runs continuously alongside the delivery children (`p2-23` … `p2-27`) — the ledger is complete exactly when every on-disk sprite has a matching row and every row points at an on-disk file.\n\nCommercial-use compatibility is non-negotiable. AI-generated output must come from a model on the approved list (`juggernaut-xl-v9`, `epicrealism-xl`, `illustrious-xl-v2`, or current equivalent per CLAUDE.md). Commissioned art must have assigned commercial rights in writing."
},
+ {
+ "id": "p2-29",
+ "title": "Welcome modal + HomePage lore + guide theme align to the player's chosen race/gender",
+ "priority": "p2",
+ "status": "missing",
+ "scope": "game1",
+ "owner": "tourguide",
+ "updated_at": "2026-04-17",
+ "summary": "The guide already exposes a welcome modal that lets the player pick a race\n(Dwarf in Game 1 — `CONCRETE_RACES = ['dwarf']`) and a gender, plus a\n`RaceThemeProvider` that merges a per-race/per-gender palette into the\nstyled-components theme. But the three surfaces don't line up:\n\n- **WelcomeModal copy** reads like a settings dialog (\"Settings\", field\n labels) rather than an invitation into the story, so the player's first\n impression is admin-UI-shaped.\n- **HomePage ``** *does* pull the player's race + name via\n `usePreferences()`, but the surrounding `` / `` / ``\n hardcode out-of-scope narrative (\"16 asymmetric races, 5 magic schools\")\n that is Game 2/3 territory and does NOT update when the player picks a\n dwarf leader.\n- **Theme application** — the palette change from `RaceThemeProvider` fires\n on confirm, but a browser already on the HomePage may not re-derive the\n Hero/Pitch colors in a visually coherent way (Cinzel serif, Dwarf copper\n `#c07040`, etc.). The \"align with welcome\" contract is not exercised.\n\nWhen the player picks **Dwarf + Female** in the modal and clicks Begin,\nall three surfaces should read as one piece: a dwarf-themed guide,\nreferring to the named dwarf leader, in Dwarf scope language (no \"5\nmagic schools\" pitch, no generic cross-race framing)."
+ },
{
"id": "g2-01",
"title": "Ley lines — Game 2 (Age of Kzzykt)",
diff --git a/scripts/apricot-run.sh b/scripts/apricot-run.sh
index 92ba399e..723ee250 100755
--- a/scripts/apricot-run.sh
+++ b/scripts/apricot-run.sh
@@ -35,21 +35,53 @@ for envfile in "${PROJECT_DIR}/.env" "${PROJECT_DIR}/.env.local"; do
fi
done
-# ── Resource policy for PARALLEL ─────────────────────────────────────
-# Precedence: explicit PARALLEL env > USE_MAX_CORES=true (nproc on RUN host)
-# > MIN_CORES from .env (default 4). Games are single-core each;
-# this controls how many run concurrently.
+# MODE + positional args resolved early so the resource-policy block can
+# peek at the seed count (which differs per mode — for `clan` it's $2
+# because $1 is the clan_id; for smoke/gpu-walltime it's $1).
+MODE="${1:?usage: apricot-run.sh [args]}"
+shift || true
+
+# ── Resource policy for PARALLEL + RAYON_NUM_THREADS ─────────────────
+# Each Godot instance spawns its own rayon thread pool for MCTS rollouts;
+# rayon defaults to nproc unless RAYON_NUM_THREADS is set. If PARALLEL
+# instances each claim all nproc threads, we get PARALLEL*nproc threads
+# fighting over nproc cores → thrashing, each process effectively single
+# core. Better: PARALLEL = number of seeds (one instance each), and
+# RAYON_NUM_THREADS = nproc / PARALLEL so the box is saturated evenly.
+case "${MODE}" in
+ clan) _seed_count_peek="${2:-10}" ;; # $1 is clan_id, $2 is seeds
+ *) _seed_count_peek="${1:-10}" ;; # smoke, gpu-walltime
+esac
+
+NPROC="$(ssh "${APRICOT}" nproc 2>/dev/null || echo 8)"
+
if [[ -n "${PARALLEL:-}" ]]; then
PARALLEL_EFFECTIVE="${PARALLEL}"
PARALLEL_SOURCE="env override"
elif [[ "${USE_MAX_CORES:-false}" == "true" ]]; then
- PARALLEL_EFFECTIVE="$(ssh "${APRICOT}" nproc 2>/dev/null || echo "${MIN_CORES:-4}")"
- PARALLEL_SOURCE="USE_MAX_CORES=true → nproc"
+ # One instance per seed — up to NPROC. More instances than that
+ # would queue serially anyway (NPROC concurrent Godots max).
+ PARALLEL_EFFECTIVE="$(( _seed_count_peek < NPROC ? _seed_count_peek : NPROC ))"
+ PARALLEL_SOURCE="USE_MAX_CORES=true → min(seeds=${_seed_count_peek}, nproc=${NPROC})"
else
PARALLEL_EFFECTIVE="${MIN_CORES:-4}"
PARALLEL_SOURCE="MIN_CORES default"
fi
export PARALLEL="${PARALLEL_EFFECTIVE}"
+
+# RAYON_NUM_THREADS per Godot instance = fair share of cores.
+if [[ -n "${RAYON_NUM_THREADS:-}" ]]; then
+ RAYON_SOURCE="env override"
+else
+ if [[ "${PARALLEL_EFFECTIVE}" -gt 0 ]]; then
+ RAYON_NUM_THREADS="$(( NPROC / PARALLEL_EFFECTIVE ))"
+ else
+ RAYON_NUM_THREADS=1
+ fi
+ [[ "${RAYON_NUM_THREADS}" -lt 1 ]] && RAYON_NUM_THREADS=1
+ RAYON_SOURCE="nproc(${NPROC}) / PARALLEL(${PARALLEL_EFFECTIVE})"
+fi
+export RAYON_NUM_THREADS
# Source + build scratch lives under $HOME/.cache (flatpak-visible via
# --filesystem=home). /tmp was tried first but flatpak's sandbox can't see
# /tmp, so Godot rejected the --path argument with "Invalid project path".
@@ -58,9 +90,6 @@ export PARALLEL="${PARALLEL_EFFECTIVE}"
SCRATCH="\$HOME/.cache/mc-src-${STAMP}" # expanded on apricot
RESULTS="\$HOME/.cache/mc-batches/${STAMP}" # expanded on apricot
-MODE="${1:?usage: apricot-run.sh [args]}"
-shift || true
-
# Resolve $HOME on apricot so SCRATCH / RESULTS are fully-qualified paths on that host.
SCRATCH_ABS="$(ssh "${APRICOT}" "echo \$HOME/.cache/mc-src-${STAMP}")"
RESULTS_ABS="$(ssh "${APRICOT}" "echo \$HOME/.cache/mc-batches/${STAMP}")"
@@ -72,6 +101,8 @@ echo " RUN host: ${APRICOT}"
echo " SCRATCH: ${SCRATCH_ABS} (per-run source + build scratch)"
echo " RESULTS: ${RESULTS_ABS} (persistent batch output)"
echo " PARALLEL: ${PARALLEL_EFFECTIVE} (source: ${PARALLEL_SOURCE})"
+echo " RAYON_NUM_THREADS/instance: ${RAYON_NUM_THREADS} (source: ${RAYON_SOURCE})"
+echo " Total CPU saturation: ${PARALLEL_EFFECTIVE} × ${RAYON_NUM_THREADS} = $((PARALLEL_EFFECTIVE * RAYON_NUM_THREADS))/${NPROC} cores"
echo " AI_GPU_ROLLOUT: ${AI_GPU_ROLLOUT:-true (default on for smoke/clan)}"
echo "============================================================"
diff --git a/tools/autoplay-batch.sh b/tools/autoplay-batch.sh
index 2c5efdfe..b1db26c3 100755
--- a/tools/autoplay-batch.sh
+++ b/tools/autoplay-batch.sh
@@ -161,6 +161,15 @@ _run_local() {
"--env=AI_PIN_PERSONALITY=${AI_PIN_PERSONALITY:-}"
"--env=MAP_SIZE=${MAP_SIZE:-}"
"--env=NUM_PLAYERS=${NUM_PLAYERS:-}"
+ "--env=AI_USE_MCTS=${AI_USE_MCTS:-}"
+ "--env=AI_GPU_ROLLOUT=${AI_GPU_ROLLOUT:-}"
+ # Rayon thread cap per Godot instance. Without this, rayon
+ # defaults to nproc (e.g. 64 on apricot); with PARALLEL=N
+ # instances each claiming 64 threads, the box has N*64 threads
+ # fighting over 64 cores → thrashing, each process effectively
+ # single-core. Caller (apricot-run.sh) computes nproc/PARALLEL
+ # so cores are divided fairly across instances.
+ "--env=RAYON_NUM_THREADS=${RAYON_NUM_THREADS:-}"
)
local GODOT_ARGS=("--path" "$GAME_DIR" "--rendering-method" "gl_compatibility")