diff --git a/app/src/football/FootballApp.jsx b/app/src/football/FootballApp.jsx
index 428730c..c716df0 100644
--- a/app/src/football/FootballApp.jsx
+++ b/app/src/football/FootballApp.jsx
@@ -12,6 +12,7 @@ import FootballHud from "./FootballHud.jsx";
import FootballTitle from "./FootballTitle.jsx";
import { Halftone, CrtOverlay } from "../ui/Overlays.jsx";
import { useGame } from "../store.js";
+import { detectLocale } from "./i18n.js";
export default function FootballApp() {
// prebootDone marks the shell ready; menuOpen raises the title/boot gate
@@ -20,7 +21,7 @@ export default function FootballApp() {
// ducks the ambient bed). `entered` latches on the first Kick Off -
// FootballTitle sets it alongside menuOpen:false.
useEffect(() => {
- useGame.setState({ prebootDone: true, menuOpen: true });
+ useGame.setState({ prebootDone: true, menuOpen: true, locale: detectLocale() });
}, []);
// The match itself already runs from boot (game.js kicks off during
diff --git a/app/src/football/FootballCanvas.jsx b/app/src/football/FootballCanvas.jsx
index 99c9092..88ef1ef 100644
--- a/app/src/football/FootballCanvas.jsx
+++ b/app/src/football/FootballCanvas.jsx
@@ -29,6 +29,10 @@ function FootballGame() {
useFrame((_, dt) => {
gameApi.frame?.(Math.min(dt, 0.05));
});
+ // After the main R3F pass: blit team FPV eye-cams into HUD canvases.
+ useFrame((_, dt) => {
+ gameApi.renderTeamFpv?.(Math.min(dt, 0.05));
+ }, -1);
return (
<>
diff --git a/app/src/football/FootballHud.jsx b/app/src/football/FootballHud.jsx
index cc8d979..a7dd6d8 100644
--- a/app/src/football/FootballHud.jsx
+++ b/app/src/football/FootballHud.jsx
@@ -3,12 +3,16 @@
// palette, zero-radius panel plates with cream keyline frames).
import Box from "@mui/material/Box";
import { keyframes } from "@mui/material/styles";
+import { useState } from "react";
import { useGame } from "../store.js";
-import { uiClick } from "../game/audio.js";
+import { uiClick, isMuted, setMuted } from "../game/audio.js";
import { ORANGE, MONO } from "../theme.js";
import { ANTON, COMIC_INK, CREAM, COMIC_ORANGE } from "../ui/comic.jsx";
import { GitHubLink } from "../ui/GitHubLink.jsx";
import { formatClock, stateLabel, eventLabel, recentEvents } from "./hud-logic.js";
+import { t, teamLabel, strategyTag } from "./i18n.js";
+import StrategyBoard from "./StrategyBoard.jsx";
+import TeamFpv from "./TeamFpv.jsx";
// ── Design tokens (shared with Hud.jsx language) ──────────────────────────
const FRAME_W = 2;
@@ -94,6 +98,7 @@ function BackArrowIcon() {
}
function BackButton() {
+ const locale = useGame((s) => s.locale) || "en";
return (
- Back
+ {t(locale, "back")}
+
+
+ );
+}
+
+function SoundMuteButton() {
+ const [muted, setMutedState] = useState(isMuted);
+ return (
+
+ {
+ const next = !muted;
+ setMuted(next);
+ setMutedState(next);
+ if (!next) uiClick();
+ }}
+ sx={{
+ appearance: "none",
+ border: "none",
+ background: "transparent",
+ color: muted ? "rgba(250,248,242,0.45)" : CREAM,
+ cursor: "pointer",
+ fontFamily: MONO,
+ fontSize: "0.65rem",
+ letterSpacing: "0.1em",
+ textTransform: "uppercase",
+ lineHeight: 1,
+ "&:hover": { color: ORANGE },
+ }}
+ >
+ {muted ? "MUTED" : "SFX"}
+
);
}
@@ -154,11 +204,12 @@ function Scoreboard() {
const score = useGame((s) => s.score);
const matchTime = useGame((s) => s.matchTime);
const matchState = useGame((s) => s.matchState);
+ const locale = useGame((s) => s.locale) || "en";
const red = score?.red ?? 0;
const blue = score?.blue ?? 0;
const time = formatClock(matchTime ?? 0);
- const label = stateLabel(matchState);
+ const label = stateLabel(matchState, locale);
const isGoal = matchState === "GOAL";
return (
@@ -232,19 +283,32 @@ function Scoreboard() {
);
}
-// ── Full-time result sticker ──────────────────────────────────────────────
+// ── Full-time result + tactics card ───────────────────────────────────────
// Reads store.matchResult ('red' | 'blue' | 'draw'), written by game.js on
-// the fulltime event. While it is still null the board keeps the plain
-// FULL TIME label, so the selector tolerates the field being absent.
+// the fulltime event. tacticsCard freezes possession / shots / strategy tags.
export function MatchResultBanner() {
const matchState = useGame((s) => s.matchState);
const matchResult = useGame((s) => s.matchResult ?? null);
+ const tacticsCard = useGame((s) => s.tacticsCard);
+ const locale = useGame((s) => s.locale) || "en";
if (matchState !== "FULLTIME" || !matchResult) return null;
const win = matchResult === "red" || matchResult === "blue";
const accent = matchResult === "red" ? RED_ACCENT : matchResult === "blue" ? BLUE_ACCENT : COMIC_ORANGE;
- const headline = win ? `${matchResult.toUpperCase()} WINS` : "DRAW";
- const sub = win ? "FULL TIME — MATCH WINNER" : "FULL TIME — HONOURS EVEN";
+ const teamName = win ? teamLabel(locale, matchResult) : "";
+ const headline = win
+ ? (locale === "zh" ? `${teamName}胜` : `${matchResult.toUpperCase()} WINS`)
+ : (locale === "zh" ? "平局" : "DRAW");
+ const sub = win
+ ? (locale === "zh" ? "全场结束 — 胜方" : "FULL TIME — MATCH WINNER")
+ : (locale === "zh" ? "全场结束 — 双方战平" : "FULL TIME — HONOURS EVEN");
+
+ const redTag = strategyTag(locale, tacticsCard?.strategy?.red);
+ const blueTag = strategyTag(locale, tacticsCard?.strategy?.blue);
+ const shotsR = tacticsCard?.shots?.red ?? 0;
+ const shotsB = tacticsCard?.shots?.blue ?? 0;
+ const possPct = Math.round(clamp01(tacticsCard?.possession?.redPct ?? 0.5) * 100);
+ const hasCard = !!tacticsCard;
return (
<>
@@ -273,6 +337,7 @@ export function MatchResultBanner() {
display: "flex",
flexDirection: "column",
alignItems: "center",
+ gap: "0.65rem",
rotate: "-1.5deg",
animation: `${slamIn} 0.45s cubic-bezier(0.2, 1.4, 0.4, 1) both`,
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
@@ -291,7 +356,6 @@ export function MatchResultBanner() {
padding: "10px 34px 12px",
boxShadow: `7px 7px 0 ${COMIC_INK}, 7px 7px 0 2px ${accent}55`,
"&::after": {
- // Diagonal hatch texture over the glass, tinted with the accent.
content: '""',
position: "absolute",
inset: 0,
@@ -330,13 +394,104 @@ export function MatchResultBanner() {
{sub}
+
+ {hasCard ? (
+
+
+ {t(locale, "cardSub")}
+
+
+ {redTag}
+
+ {t(locale, "boardVs")}
+
+ {blueTag}
+
+
+
+ {t(locale, "boardShots")}{" "}
+
+ {shotsR}
+
+ –
+
+ {shotsB}
+
+
+
+ {t(locale, "boardPoss")}{" "}
+
+ {possPct}%
+
+
+
+
+ ) : null}
>
);
}
+function clamp01(v) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return 0.5;
+ return Math.max(0, Math.min(1, n));
+}
+
function EventTicker() {
const matchEvents = useGame((s) => s.matchEvents);
+ const locale = useGame((s) => s.locale) || "en";
const events = recentEvents(matchEvents, 3);
if (events.length === 0) return null;
@@ -345,7 +500,7 @@ function EventTicker() {
- {eventLabel(ev)}
+ {eventLabel(ev, locale)}
))}
@@ -388,6 +543,7 @@ function EventTicker() {
function PenaltyIndicator() {
const ducksState = useGame((s) => s.ducksState);
+ const locale = useGame((s) => s.locale) || "en";
if (!ducksState) return null;
const penalized = ducksState.filter((d) => d.penalized);
if (penalized.length === 0) return null;
@@ -396,13 +552,16 @@ function PenaltyIndicator() {
{penalized.map((d) => (
@@ -419,7 +578,7 @@ function PenaltyIndicator() {
textTransform: "uppercase",
}}
>
- #{d.id} SIN-BIN
+ #{d.id} {t(locale, "sinBin")}
))}
@@ -438,15 +597,22 @@ export default function FootballHud() {
}}
>
+
- {/* Top-right corner is free (scoreboard centres, Back sits top-left):
- the repo link keeps its title-screen SPOT metrics, flipped to
- fixed and re-armed for clicks inside this pointer-events:none
- shell. */}
-
+
+
);
}
diff --git a/app/src/football/FootballTitle.jsx b/app/src/football/FootballTitle.jsx
index b351dad..243e827 100644
--- a/app/src/football/FootballTitle.jsx
+++ b/app/src/football/FootballTitle.jsx
@@ -25,6 +25,9 @@ import { INK, ORANGE, MONO } from "../theme.js";
import { ComicButton, ComicTitle, HalftoneRamp, ANTON, CREAM } from "../ui/comic.jsx";
import { PreorderButton } from "../ui/Hud.jsx";
import { GitHubLink } from "../ui/GitHubLink.jsx";
+import StrategyPanel from "./StrategyPanel.jsx";
+import LangToggle from "./LangToggle.jsx";
+import { t } from "./i18n.js";
const rowIn = keyframes`
from { transform: translateY(12px); opacity: 0; }
@@ -68,11 +71,7 @@ const Kbd = styled("kbd")(() => ({
// Spectator cheat strip: football has no manual control (AI drives all six
// ducks), so the arcade instruction card lists camera verbs only.
-const SPECTATOR_SHORTCUTS = [
- { caps: ["Scroll"], name: "Zoom" },
- { caps: ["Drag"], name: "Orbit" },
- { caps: ["R"], name: "Reset Camera" },
-];
+// Labels come from i18n at render time.
// #rrggbb -> rgba() at the halftone's alpha.
const tint = (hex, a) =>
@@ -84,6 +83,8 @@ export default function FootballTitle({ onKickOff }) {
const bootFailed = useGame((s) => s.bootFailed);
const padConnected = useGame((s) => s.padConnected);
const touchMode = useGame((s) => s.touchMode);
+ const entered = useGame((s) => s.entered);
+ const locale = useGame((s) => s.locale) || "en";
const [closing, setClosing] = useState(false);
const prevOpen = useRef(menuOpen);
// Latches once the kickoff has fired, so a pause reopen reads "Resume"
@@ -227,8 +228,18 @@ export default function FootballTitle({ onKickOff }) {
if (!menuOpen && !closing) return null;
// After the first kickoff the overlay is a pause screen.
- const ctaLabel = kickoffFired.current ? "Resume" : "Kick Off";
- const enterHint = padConnected ? "press A" : touchMode ? null : "press Enter";
+ const ctaLabel = kickoffFired.current ? t(locale, "resume") : t(locale, "kickOff");
+ const enterHint = padConnected
+ ? t(locale, "pressA")
+ : touchMode
+ ? null
+ : t(locale, "pressEnter");
+
+ const SPECTATOR = [
+ { caps: ["Scroll"], name: t(locale, "zoom") },
+ { caps: ["Drag"], name: t(locale, "orbit") },
+ { caps: ["R"], name: t(locale, "resetCamera") },
+ ];
return (
}
{ready && }
+ {ready && }
{/* Boot gate: one centered spinner on bare ink with the match's
step line until fonts, brand art and the game core are in. A
@@ -366,7 +378,7 @@ export default function FootballTitle({ onKickOff }) {
color: "rgba(255, 255, 255, 0.5)",
}}
>
- Loading teams...
+ {t(locale, "loadingTeams")}
)}
@@ -403,7 +415,7 @@ export default function FootballTitle({ onKickOff }) {
...row(0.08),
}}
>
- Boot failed - reload to retry
+ {t(locale, "bootFailed")}
) : (
<>
@@ -435,18 +447,21 @@ export default function FootballTitle({ onKickOff }) {
...row(0.24),
}}
>
- Six ducks. One ball. Zero joysticks - the same trained policies
- that drive the real robot play the whole match themselves.
+ {t(locale, "tagline")}
+
+
+
+
{/* CTA + key prompt travel as one block, centred under the title. */}
@@ -546,7 +561,7 @@ export default function FootballTitle({ onKickOff }) {
},
}}
>
- {SPECTATOR_SHORTCUTS.map((s) => (
+ {SPECTATOR.map((s) => (
- spectator mode - the ducks play themselves
+ {t(locale, "coachFooter")}
>
diff --git a/app/src/football/LangToggle.jsx b/app/src/football/LangToggle.jsx
new file mode 100644
index 0000000..9d94e3d
--- /dev/null
+++ b/app/src/football/LangToggle.jsx
@@ -0,0 +1,78 @@
+// EN / 中 toggle for football title & pause overlay.
+import Box from "@mui/material/Box";
+import { useGame } from "../store.js";
+import { ORANGE, MONO } from "../theme.js";
+import { ANTON, CREAM, COMIC_INK } from "../ui/comic.jsx";
+import { persistLocale, t } from "./i18n.js";
+
+export default function LangToggle({ sx }) {
+ const locale = useGame((s) => s.locale) || "en";
+
+ const setLocale = (next) => {
+ if (next === locale) return;
+ persistLocale(next);
+ useGame.setState({ locale: next });
+ };
+
+ const chip = (id, label) => {
+ const on = locale === id;
+ return (
+ setLocale(id)}
+ sx={{
+ appearance: "none",
+ cursor: "pointer",
+ border: `2px solid ${on ? ORANGE : "rgba(255,255,255,0.22)"}`,
+ borderRadius: 0,
+ background: on ? "rgba(255,122,47,0.2)" : "rgba(0,0,0,0.35)",
+ color: on ? CREAM : "rgba(255,255,255,0.7)",
+ fontFamily: id === "zh" ? MONO : ANTON,
+ fontSize: "0.72rem",
+ fontWeight: 700,
+ letterSpacing: id === "en" ? "0.08em" : "0.02em",
+ lineHeight: 1,
+ minWidth: "2.1rem",
+ px: "0.5rem",
+ py: "0.4rem",
+ transition: "border-color 0.12s ease, background 0.12s ease",
+ WebkitTapHighlightColor: "transparent",
+ "&:hover": { borderColor: ORANGE },
+ "&:focus-visible": {
+ outline: `2px dashed ${CREAM}`,
+ outlineOffset: 2,
+ },
+ }}
+ >
+ {label}
+
+ );
+ };
+
+ return (
+
+ {chip("en", t(locale, "langEn"))}
+ {chip("zh", t(locale, "langZh"))}
+
+ );
+}
diff --git a/app/src/football/StrategyBoard.jsx b/app/src/football/StrategyBoard.jsx
new file mode 100644
index 0000000..67c1a9a
--- /dev/null
+++ b/app/src/football/StrategyBoard.jsx
@@ -0,0 +1,235 @@
+// Live cumulative match report — possession tug + shots + strategy tags.
+import Box from "@mui/material/Box";
+import { useGame } from "../store.js";
+import { uiClick } from "../game/audio.js";
+import { MONO } from "../theme.js";
+import { ANTON, CREAM, COMIC_INK } from "../ui/comic.jsx";
+import { DEFAULT_STRATEGY, normalizeStrategy } from "../game/football/strategy.js";
+import {
+ t,
+ formationLabel,
+ strategyTag,
+ teamLabel,
+} from "./i18n.js";
+
+const RED_ACCENT = "#ff4466";
+const BLUE_ACCENT = "#4488ff";
+const GLASS = "rgba(16, 16, 24, 0.88)";
+
+function PossBar({ redPct, locale }) {
+ const pct = Math.round(clamp01(redPct) * 100);
+ return (
+
+
+ {pct}%
+ {t(locale, "boardPoss")}
+ {100 - pct}%
+
+
+
+
+
+
+ );
+}
+
+function clamp01(v) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return 0.5;
+ return Math.max(0, Math.min(1, n));
+}
+
+function TeamTags({ team, strategy, locale, isUser, onOpenCoach }) {
+ const accent = team === "red" ? RED_ACCENT : BLUE_ACCENT;
+ const strat = normalizeStrategy(strategy || DEFAULT_STRATEGY);
+ const line = [
+ formationLabel(locale, strat.formation),
+ strategyTag(locale, strat),
+ ].join(" · ");
+
+ return (
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onOpenCoach?.();
+ }
+ } : undefined}
+ >
+
+ {teamLabel(locale, team)}
+
+
+ {line}
+
+
+ );
+}
+
+/**
+ * Bottom report: red tags | possession + shots | blue tags.
+ * Click user side to reopen coach desk.
+ */
+export default function StrategyBoard() {
+ const entered = useGame((s) => s.entered);
+ const menuOpen = useGame((s) => s.menuOpen);
+ const matchState = useGame((s) => s.matchState);
+ const locale = useGame((s) => s.locale) || "en";
+ const userTeam = useGame((s) => s.userTeam) || "red";
+ const board = useGame((s) => s.tacticsBoard);
+
+ // Hide under the fulltime tactics card — avoid duplicating the same numbers.
+ if (!entered || menuOpen || matchState === "FULLTIME") return null;
+
+ const openCoach = () => {
+ uiClick();
+ useGame.setState({ menuOpen: true });
+ };
+
+ const redPct = board?.possession?.redPct ?? 0.5;
+ const shotsR = board?.shots?.red ?? 0;
+ const shotsB = board?.shots?.blue ?? 0;
+
+ return (
+ *": { pointerEvents: "auto" },
+ }}
+ >
+
+
+
+ {shotsR}
+
+ {t(locale, "boardShots")}
+
+ {shotsB}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/football/StrategyPanel.jsx b/app/src/football/StrategyPanel.jsx
new file mode 100644
index 0000000..64042f7
--- /dev/null
+++ b/app/src/football/StrategyPanel.jsx
@@ -0,0 +1,210 @@
+// Coach tactics panel for football mode: pick side, formation, style, press.
+// Style/press apply live; formation is locked after Kick Off (parent passes
+// formationLocked). Pure UI — writes through gameApi when booted, else store.
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import { useGame, gameApi } from "../store.js";
+import { MONO, ORANGE } from "../theme.js";
+import { ANTON, CREAM, COMIC_INK } from "../ui/comic.jsx";
+import {
+ DEFAULT_STRATEGY,
+ FORMATION_IDS,
+ STYLE_IDS,
+ PRESS_IDS,
+ normalizeStrategy,
+} from "../game/football/strategy.js";
+import {
+ t,
+ formationLabel,
+ styleLabel,
+ pressLabel,
+ teamLabel,
+} from "./i18n.js";
+
+const RED_ACCENT = "#ff4466";
+const BLUE_ACCENT = "#4488ff";
+
+function patchStrategy(partial, opts) {
+ if (typeof gameApi.setTeamStrategy === "function") {
+ return gameApi.setTeamStrategy(partial, opts);
+ }
+ const prev = useGame.getState().userStrategy || DEFAULT_STRATEGY;
+ const next = normalizeStrategy({ ...prev, ...partial });
+ useGame.setState({ userStrategy: next });
+ return next;
+}
+
+function patchTeam(team) {
+ if (typeof gameApi.setUserTeam === "function") {
+ return gameApi.setUserTeam(team);
+ }
+ useGame.setState({ userTeam: team });
+ return team;
+}
+
+function ChipGroup({ label, options, labels, value, onChange, disabled }) {
+ return (
+
+
+ {label}
+
+
+ {options.map((id) => {
+ const on = value === id;
+ return (
+ onChange(id)}
+ sx={{
+ appearance: "none",
+ cursor: disabled ? "default" : "pointer",
+ border: `2px solid ${on ? ORANGE : "rgba(255,255,255,0.22)"}`,
+ borderRadius: 0,
+ background: on ? "rgba(255,122,47,0.18)" : "rgba(0,0,0,0.25)",
+ color: on ? CREAM : "rgba(255,255,255,0.72)",
+ fontFamily: ANTON,
+ fontSize: "0.72rem",
+ letterSpacing: "0.06em",
+ textTransform: "uppercase",
+ lineHeight: 1,
+ px: "0.65rem",
+ py: "0.45rem",
+ opacity: disabled ? 0.45 : 1,
+ transition: "border-color 0.12s ease, background 0.12s ease",
+ WebkitTapHighlightColor: "transparent",
+ "&:hover": disabled ? undefined : { borderColor: ORANGE },
+ "&:focus-visible": {
+ outline: `2px dashed ${CREAM}`,
+ outlineOffset: 2,
+ },
+ }}
+ >
+ {labels[id] || id}
+
+ );
+ })}
+
+
+ );
+}
+
+/**
+ * @param {{ compact?: boolean, formationLocked?: boolean }} props
+ */
+export default function StrategyPanel({ compact = false, formationLocked = false }) {
+ const userTeam = useGame((s) => s.userTeam) || "red";
+ const userStrategy = useGame((s) => s.userStrategy) || DEFAULT_STRATEGY;
+ const locale = useGame((s) => s.locale) || "en";
+ const strat = normalizeStrategy(userStrategy);
+
+ const formationLabels = Object.fromEntries(
+ FORMATION_IDS.map((id) => [id, formationLabel(locale, id)]),
+ );
+ const styleLabels = Object.fromEntries(
+ STYLE_IDS.map((id) => [id, styleLabel(locale, id)]),
+ );
+ const pressLabels = Object.fromEntries(
+ PRESS_IDS.map((id) => [id, pressLabel(locale, id)]),
+ );
+
+ return (
+
+
+ {t(locale, "coachDesk")}
+
+
+ patchTeam(tm)}
+ disabled={formationLocked}
+ />
+
+
+ patchStrategy({ formation })}
+ />
+ patchStrategy({ style })}
+ />
+ patchStrategy({ press })}
+ />
+
+
+
+ {teamLabel(locale, userTeam)} · {formationLabels[strat.formation]} · {styleLabels[strat.style]} · {t(locale, "pressWord")} {pressLabels[strat.press]}
+ {formationLocked ? ` · ${t(locale, "formationLocked")}` : ""}
+
+
+ );
+}
diff --git a/app/src/football/TeamFpv.jsx b/app/src/football/TeamFpv.jsx
new file mode 100644
index 0000000..7d84519
--- /dev/null
+++ b/app/src/football/TeamFpv.jsx
@@ -0,0 +1,141 @@
+// Dual first-person vision at the top — red left, blue right.
+// Each slot owns an opaque
- {/* Sound is fully disabled for now (SOUND_DISABLED in audio.js) -
- the mute toggle comes back with the finished sound design. */}
- {/* */}
+
);
}
diff --git a/app/test/ai.test.js b/app/test/ai.test.js
index 9319b84..2e58d0a 100644
--- a/app/test/ai.test.js
+++ b/app/test/ai.test.js
@@ -7,9 +7,10 @@ import assert from 'node:assert/strict';
import {
angleTo, distanceTo, normalizeAngle, angleDiff, clamp, lerp,
BaseAgent, ForwardAgent, DefenderAgent, GoalkeeperAgent,
- VX_MAX, VX_MIN, WZ_MAX, SHOOT_SPEED,
- createAgent, decideAll,
+ VX_MAX, VX_MIN, WZ_MAX, SHOOT_SPEED, SHOOT_DIST,
+ createAgent, decideAll, assignChaserId, chaseCost, chaseApproachPoint,
} from '../src/game/football/ai/index.js';
+import { FIELD_HALF_L } from '../src/game/football/constants.js';
/**
* Build a minimal gameState for tests.
@@ -396,6 +397,93 @@ describe('decideAll', () => {
const cmds = decideAll([], { ball: { x: 0, y: 0, vx: 0, vy: 0 }, allDucks: [], team: 'red' });
assert.deepEqual(cmds, []);
});
+
+ it('kickoff chaser closes inside SHOOT_DIST instead of orbiting the approach ring', () => {
+ // Reproduce: spawn at kickoff, integrate CHASE until near the old offset
+ // parking spot (~0.4 m). Must keep driving at the ball and eventually kick.
+ const ducks = [
+ { id: 0, pos: [-0.8, 0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: 0.5 },
+ { id: 1, pos: [-0.8, -0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: -0.5 },
+ { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red', spawnX: -2.8, spawnY: 0 },
+ ];
+ const ball = { x: 0, y: 0, vx: 0, vy: 0 };
+ const gs = { ball, allDucks: ducks, team: 'red' };
+ const dt = 0.1;
+ let kicked = false;
+ let minBd = Infinity;
+ for (let t = 0; t < 120; t++) {
+ const cmds = decideAll(ducks, gs);
+ const d0 = Math.hypot(ducks[0].pos[0] - ball.x, ducks[0].pos[1] - ball.y);
+ const d1 = Math.hypot(ducks[1].pos[0] - ball.x, ducks[1].pos[1] - ball.y);
+ const ci = d0 <= d1 ? 0 : 1;
+ const c = cmds[ci];
+ const duck = ducks[ci];
+ duck.yaw += c.wz * dt;
+ duck.pos[0] += Math.cos(duck.yaw) * c.vx * dt;
+ duck.pos[1] += Math.sin(duck.yaw) * c.vx * dt;
+ const bd = Math.hypot(duck.pos[0] - ball.x, duck.pos[1] - ball.y);
+ if (bd < minBd) minBd = bd;
+ if (c.kick) { kicked = true; break; }
+ }
+ assert.ok(minBd <= SHOOT_DIST, `never reached shoot range (minBd=${minBd})`);
+ assert.ok(kicked, 'chaser never issued a kick after closing on the ball');
+ });
+
+ it('chaseCost prefers a facing duck over a slightly closer back-to-ball duck', () => {
+ const ball = { x: 0, y: 0 };
+ const facing = { id: 0, pos: [-0.9, 0], yaw: 0, role: 'forward' }; // faces +X toward ball
+ const closerAway = { id: 1, pos: [-0.75, 0], yaw: Math.PI, role: 'forward' }; // closer but faces away
+ assert.ok(chaseCost(facing, ball) < chaseCost(closerAway, ball));
+ assert.equal(assignChaserId([facing, closerAway], ball), 0);
+ });
+
+ it('chase hysteresis keeps the previous chaser when costs are close', () => {
+ const ball = { x: 0, y: 0 };
+ const a = { id: 0, pos: [-0.8, 0.1], yaw: 0, role: 'forward' };
+ const b = { id: 1, pos: [-0.78, -0.1], yaw: 0, role: 'forward' };
+ // Without hysteresis b is slightly closer; with prev=0, a should stick.
+ assert.equal(assignChaserId([a, b], ball, -1), 1);
+ assert.equal(assignChaserId([a, b], ball, 0), 0);
+ });
+
+ it('chaseApproachPoint sits behind a still ball on the shot axis (MoveToStaticBall)', () => {
+ const ball = { x: 0, y: 0, vx: 0, vy: 0 };
+ const ap = chaseApproachPoint(ball, FIELD_HALF_L, -0.8, 0.5);
+ // Red attacks +X → approach is at −r on X, near centre line
+ assert.ok(ap.x < -0.2 && ap.x > -0.4, `expected behind ball, got x=${ap.x}`);
+ assert.ok(Math.abs(ap.y) < 0.05, `expected on shot axis, got y=${ap.y}`);
+ });
+
+ it('chaseApproachPoint leads a fast ball with a short prediction', () => {
+ const ball = { x: 0, y: 0, vx: 0.8, vy: 0 };
+ const apStill = chaseApproachPoint({ x: 0, y: 0, vx: 0, vy: 0 }, FIELD_HALF_L, -1, 0);
+ const apFast = chaseApproachPoint(ball, FIELD_HALF_L, -1, 0);
+ assert.ok(apFast.x > apStill.x, 'fast ball approach should shift toward travel direction');
+ });
+
+ it('non-chaser support stays near the ball instead of deep spawn mirror', () => {
+ const ducks = [
+ { id: 0, pos: [-0.8, 0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: 0.5, _ai: {} },
+ { id: 1, pos: [-0.8, -0.5], yaw: 0, role: 'forward', fallen: false, penalized: false, team: 'red', spawnX: -0.8, spawnY: -0.5, _ai: {} },
+ { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', fallen: false, penalized: false, team: 'red', spawnX: -2.8, spawnY: 0 },
+ ];
+ // Ball in attack half; duck 0 is chaser (closer). Duck 1 should move toward support near ball, not x≈1.3 spawn advance.
+ ducks[0].pos = [-0.2, 0.2];
+ const ball = { x: 0.5, y: 0, vx: 0, vy: 0 };
+ const cmds = decideAll(ducks, { ball, allDucks: ducks, team: 'red' });
+ assert.equal(ducks[0]._ai.holdingChase, true);
+ // Support command: positive vx toward +X (ball side) rather than spinning in place only
+ assert.ok(cmds[1].vx > 0 || Math.abs(cmds[1].wz) > 0);
+ // Integrate a few ticks — support should reduce distance to a near-ball lane (x around ball+ahead)
+ for (let t = 0; t < 25; t++) {
+ const c = decideAll(ducks, { ball, allDucks: ducks, team: 'red' })[1];
+ ducks[1].yaw += c.wz * 0.1;
+ ducks[1].pos[0] += Math.cos(ducks[1].yaw) * c.vx * 0.1;
+ ducks[1].pos[1] += Math.sin(ducks[1].yaw) * c.vx * 0.1;
+ }
+ assert.ok(ducks[1].pos[0] > -0.3, `support still deep (x=${ducks[1].pos[0]})`);
+ assert.ok(Math.abs(ducks[1].pos[0] - ball.x) < 1.2, 'support should linger near ball lane');
+ });
});
// ═══════════════════════════════════════════════════════════════════════════════
diff --git a/app/test/strategy.test.js b/app/test/strategy.test.js
new file mode 100644
index 0000000..f70ef48
--- /dev/null
+++ b/app/test/strategy.test.js
@@ -0,0 +1,65 @@
+// Strategy preset → spawn / TUNE overlay tests (no engine deps).
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ DEFAULT_STRATEGY,
+ normalizeStrategy,
+ getTuneOverlay,
+ buildSpawnTable,
+ strategiesForUser,
+} from '../src/game/football/strategy.js';
+import { decideAll, BASE_TUNE } from '../src/game/football/ai/index.js';
+
+describe('strategy presets', () => {
+ it('normalizeStrategy falls back to defaults', () => {
+ assert.deepEqual(normalizeStrategy({}), DEFAULT_STRATEGY);
+ assert.equal(normalizeStrategy({ formation: 'nope' }).formation, '2f1gk');
+ assert.equal(normalizeStrategy({ style: 'attack' }).style, 'attack');
+ });
+
+ it('attack overlay pushes formation advance higher than defend', () => {
+ const atk = getTuneOverlay({ style: 'attack', press: 'medium' });
+ const def = getTuneOverlay({ style: 'defend', press: 'medium' });
+ assert.ok(atk.FORMATION_X_ADVANCE > (def.FORMATION_X_ADVANCE ?? BASE_TUNE.FORMATION_X_ADVANCE));
+ assert.ok(def.FORMATION_X_RETREAT < (atk.FORMATION_X_RETREAT ?? BASE_TUNE.FORMATION_X_RETREAT));
+ });
+
+ it('buildSpawnTable enables defender roles for 1f1d1gk', () => {
+ const table = buildSpawnTable({
+ red: { formation: '1f1d1gk', style: 'balanced', press: 'medium' },
+ blue: { ...DEFAULT_STRATEGY },
+ });
+ assert.equal(table[0].role, 'forward');
+ assert.equal(table[1].role, 'defender');
+ assert.equal(table[2].role, 'goalkeeper');
+ assert.equal(table[3].role, 'forward');
+ assert.equal(table[4].role, 'forward'); // blue still 2F
+ assert.equal(table[5].role, 'goalkeeper');
+ });
+
+ it('strategiesForUser only overrides the user team', () => {
+ const map = strategiesForUser('blue', { formation: '1f1d1gk', style: 'attack', press: 'high' });
+ assert.equal(map.blue.formation, '1f1d1gk');
+ assert.equal(map.red.formation, '2f1gk');
+ assert.equal(map.red.style, 'balanced');
+ });
+
+ it('decideAll accepts tuneOverlay without breaking chase', () => {
+ const ducks = [
+ { id: 0, pos: [0, 0], yaw: 0, role: 'forward', team: 'red', spawnX: -0.8, spawnY: 0.5 },
+ { id: 1, pos: [-1, 0.5], yaw: 0, role: 'forward', team: 'red', spawnX: -0.8, spawnY: -0.5 },
+ { id: 2, pos: [-2.8, 0], yaw: 0, role: 'goalkeeper', team: 'red', spawnX: -2.8, spawnY: 0 },
+ ];
+ const gs = {
+ ball: { x: 0.2, y: 0, vx: 0, vy: 0 },
+ allDucks: ducks,
+ team: 'red',
+ tuneOverlay: getTuneOverlay({ style: 'attack', press: 'high' }),
+ };
+ const cmds = decideAll(ducks, gs);
+ assert.equal(cmds.length, 3);
+ assert.ok(Number.isFinite(cmds[0].vx));
+ assert.equal(typeof cmds[0].kick, 'boolean');
+ });
+});
diff --git a/app/test/tactics-board.test.js b/app/test/tactics-board.test.js
new file mode 100644
index 0000000..324162e
--- /dev/null
+++ b/app/test/tactics-board.test.js
@@ -0,0 +1,129 @@
+// Unit tests for cumulative match stats / report board.
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ createMatchStats,
+ buildMatchReport,
+ buildTacticsBoard,
+ pickChaserId,
+} from '../src/game/football/tactics-board.js';
+import { isGoalDirectedShot } from '../src/game/football/match-stats.js';
+
+describe('isGoalDirectedShot', () => {
+ it('accepts a red strike at feet facing +X goal in the attack half', () => {
+ assert.equal(
+ isGoalDirectedShot('red', { x: 1.2, y: 0 }, { x: 0.9, y: 0, yaw: 0 }),
+ true,
+ );
+ });
+
+ it('rejects midfield / own-half pokes', () => {
+ assert.equal(
+ isGoalDirectedShot('red', { x: -0.5, y: 0 }, { x: -0.6, y: 0, yaw: 0 }),
+ false,
+ );
+ });
+
+ it('rejects kicks while facing away from goal', () => {
+ assert.equal(
+ isGoalDirectedShot('red', { x: 1.0, y: 0 }, { x: 0.7, y: 0, yaw: Math.PI }),
+ false,
+ );
+ });
+
+ it('rejects kicks when the ball is not at the feet', () => {
+ assert.equal(
+ isGoalDirectedShot('red', { x: 1.5, y: 0 }, { x: 0.5, y: 0, yaw: 0 }),
+ false,
+ );
+ });
+});
+
+describe('match-stats', () => {
+ it('accumulates possession only while PLAYING', () => {
+ const stats = createMatchStats();
+ stats.tick(1.0, { matchState: 'KICKOFF', lastTouchTeam: 'red' });
+ stats.tick(2.0, { matchState: 'PLAYING', lastTouchTeam: 'red' });
+ stats.tick(1.0, { matchState: 'PLAYING', lastTouchTeam: 'blue' });
+ const snap = stats.snapshot();
+ assert.equal(snap.possession.red, 2);
+ assert.equal(snap.possession.blue, 1);
+ assert.ok(Math.abs(snap.possession.redPct - 2 / 3) < 1e-9);
+ });
+
+ it('only counts goal-directed shots, with cooldown', () => {
+ const stats = createMatchStats();
+ const aim = { x: 0.9, y: 0, yaw: 0 };
+ assert.equal(stats.tryNoteShot('red', { x: 1.2, y: 0 }, aim), true);
+ assert.equal(stats.tryNoteShot('red', { x: 1.3, y: 0 }, aim), false); // cooldown
+ // Scramble kick facing wrong way — not a shot
+ assert.equal(
+ stats.tryNoteShot('blue', { x: -1.0, y: 0 }, { x: -0.7, y: 0, yaw: 0 }),
+ false,
+ );
+ // Blue facing their goal (−X)
+ assert.equal(
+ stats.tryNoteShot('blue', { x: -1.2, y: 0 }, { x: -0.9, y: 0, yaw: Math.PI }),
+ true,
+ );
+ const snap = stats.snapshot();
+ assert.equal(snap.shots.red, 1);
+ assert.equal(snap.shots.blue, 1);
+ });
+
+ it('reset clears counters', () => {
+ const stats = createMatchStats();
+ stats.tick(3, { matchState: 'PLAYING', lastTouchTeam: 'red' });
+ stats.tryNoteShot('red', { x: 1.2, y: 0 }, { x: 0.9, y: 0, yaw: 0 });
+ stats.reset();
+ const snap = stats.snapshot();
+ assert.equal(snap.possession.red, 0);
+ assert.equal(snap.shots.red, 0);
+ assert.equal(snap.possession.redPct, 0.5);
+ });
+});
+
+describe('buildMatchReport', () => {
+ it('attaches normalized strategies and score', () => {
+ const stats = createMatchStats();
+ stats.tick(4, { matchState: 'PLAYING', lastTouchTeam: 'red' });
+ stats.tick(1, { matchState: 'PLAYING', lastTouchTeam: 'blue' });
+ stats.tryNoteShot('red', { x: 1.2, y: 0 }, { x: 0.9, y: 0, yaw: 0 });
+ const report = buildMatchReport({
+ stats,
+ strategyByTeam: {
+ red: { formation: '1f1d1gk', style: 'attack', press: 'high' },
+ blue: { style: 'defend' },
+ },
+ score: { red: 2, blue: 1 },
+ });
+ assert.equal(report.strategy.red.style, 'attack');
+ assert.equal(report.strategy.red.press, 'high');
+ assert.equal(report.strategy.blue.style, 'defend');
+ assert.equal(report.strategy.blue.press, 'medium');
+ assert.equal(report.shots.red, 1);
+ assert.equal(report.score.red, 2);
+ assert.ok(report.possession.redPct > 0.7);
+ });
+
+ it('buildTacticsBoard is an alias of buildMatchReport', () => {
+ const stats = createMatchStats();
+ const a = buildTacticsBoard({ stats, strategyByTeam: {} });
+ const b = buildMatchReport({ stats, strategyByTeam: {} });
+ assert.deepEqual(a.shots, b.shots);
+ assert.deepEqual(a.strategy, b.strategy);
+ });
+});
+
+describe('pickChaserId', () => {
+ it('still picks nearest non-GK field duck', () => {
+ const ball = { x: 0.1, y: 0 };
+ const red = [
+ { id: 0, team: 'red', role: 'forward', pos: [-0.5, 0.4], yaw: 0 },
+ { id: 1, team: 'red', role: 'forward', pos: [-1.0, -0.4], yaw: 0 },
+ { id: 2, team: 'red', role: 'goalkeeper', pos: [-2.8, 0], yaw: 0 },
+ ];
+ assert.equal(pickChaserId(red, ball), 0);
+ });
+});