Imported from FantasyWiki/FantasyWiki (
AGENTS.md). Install upstream withnpx skills add FantasyWiki/FantasyWiki. Copyright stays with the author.
AGENTS.md
Guidance for coding agents working in this repository. Claude Code reads it
through CLAUDE.md, GitHub Copilot reads it directly, and it is
the one copy: a second set of instructions is a second set that drifts. How AI
was used to build this project is declared in
AI-DECLARATION.md; keep that file true when the way
you work changes.
Project overview
FantasyWiki is a fantasy-sports-style game built on top of Wikipedia article pageview trends (see PRODUCT.md). Players buy/manage "article contracts," build formations, and compete in leagues. Domain terminology (Top Read Snapshot, Article Availability, Chemistry Link, etc.) is canonically defined in CONTEXT.md, use that vocabulary in code, comments, and commit messages rather than ad-hoc synonyms.
Repository layout
This is a Gradle-orchestrated monorepo with two Node subprojects, a Kotlin/JVM module, and shared TypeScript packages:
FantasyWiki/
├── dto/ # Shared DTOs (API request/response shapes) used by both frontend and backend
├── model/ # Shared domain model types
├── backend/ # Cloudflare Worker (Hono)
├── frontend/ # Vue 3 + Ionic SPA
└── scoring-collector/ # Kotlin/JVM nightly collector, shipped as a container image
Common commands
Root (Gradle, runs every subproject)
./gradlew check # npm_ci + frontend:check + backend:check + scoring-collector:check (format, lint, test on both persistence targets, audit)
./gradlew dev # frontend devNoMock + backend wrangler dev (D1), Article Genie on
./gradlew devNoGenie # the same, Genie off - the one needing no Cloudflare account
./gradlew devMongo # the same, with the backend persisting to MongoDB, Genie off
./gradlew devMock # frontend devMock (MSW) + backend wrangler dev, Genie off
./gradlew fix # frontend/backend formatfix + lintfix, collector ktlintFormat
The same app in Docker, one name per combination (see
docs/development/docker-local-dev.md). up/demo need a CLOUDFLARE_API_TOKEN
in backend/.dev.vars; the other two need nothing:
./gradlew up # dev servers, Article Genie on
./gradlew noGenie # dev servers, Article Genie off
./gradlew demo # ...with the demo league seeded, Genie on
./gradlew demoNoGenie # ...with the demo league seeded, Genie off
Frontend (cd frontend)
npm run dev # vite dev server
npm run build # vue-tsc + vite build
npm run test # vitest run (all unit tests)
npm run hot-test # vitest watch mode
npm run lint / lintfix
npm run format / formatfix
npm run g:component # Plop generator: creates src/views/<Name>.vue + src/tests/<Name>.spec.ts
Run a single test file:
npx vitest run src/tests/auth/LoginPage.spec.ts
Backend (cd backend)
npm run dev # wrangler dev --env local (runs D1 migrations first via Gradle)
npm run devmongo # wrangler dev on MongoDB (wrangler.mongo.jsonc; needs a local replica set)
npm run test # vitest run (Cloudflare Workers pool, D1), what Gradle check runs
npm run testmongo # the same suite against MongoDB (starts its own replica set)
npm run test:integration # vitest run --config vitest.config.ts (single run)
npm run test-coverage # coverage report (uploaded to Codecov in CI)
npm run lint / lintfix
npm run format / formatfix
npm run cf-typegen # regenerate CloudflareBindings types from wrangler.jsonc
npm run db:init:local # apply D1 migrations locally
npm run db:migrate:remote # apply D1 migrations to remote D1
Backend tests use @cloudflare/vitest-pool-workers, load config from wrangler.jsonc, and reset the database before each test, D1 by dropping the schema and replaying backend/migrations/, MongoDB by emptying every collection and re-seeding repositories/mongo/bootstrap.ts.
Which layer a test may name is a rule, enforced by no-restricted-imports: nothing under src/services, src/routes or src/tests may import repositories/d1/** or repositories/mongo/**, composition.ts is the only module that chooses an implementation. Tests reach persistence through repositories() from src/tests/support/target.ts, seed through src/tests/support/subjects.ts (never SQL, and never with defaulted fixture values), and put target-specific facts under src/tests/repositories/<target>. Repository promises every implementation must keep go in src/tests/repositories/conformance. See docs/development/backend-testing.md.
In CI the two persistence targets are two legs of a matrix in check.yml; locally ./gradlew check runs them one after the other.
Architecture
Backend (backend/src)
Layered structure, each layer only talks to the one below it:
- Routes (
routes/), parse input, enforce auth/HTTP constraints, call services, map results to HTTP responses. One file per resource or concern,auth.ts,leagues.ts,me.ts,session.ts,notifications.tsand the rest, plusinternal.ts, which is the scoring collector's service-token surface and sits outside the/api/*JWT guard. - Services (
services/), business logic/orchestration. Depend on repository interfaces, return typedResultvalues consumed by routes. - Repositories (
repositories/), define contracts (e.g.playerRepository.ts) with one implementation per store underrepositories/d1/andrepositories/mongo/(e.g.playerRepositoryD1.ts,playerRepositoryMongo.ts). Queries and persistence error handling live here, and a store's own error wording never leaves the layer.
Runtime is a Cloudflare Worker using Hono (backend/src/index.ts). A deployment persists to either Cloudflare D1 (the db binding, the default) or MongoDB (PERSISTENCE=mongo plus MONGO_URL); composition.ts is the only module that picks, and it is synchronous because the request middleware, the settlement Workflow and the test seam all call it without awaiting. No Cloudflare deployment runs on MongoDB: wrangler.jsonc aliases the driver away so a D1 deploy neither carries it nor needs a compatibility flag. The MongoDB target runs locally, through backend/wrangler.mongo.jsonc, its own file because alias has no per-environment form and because a config that can never be deployed does not belong in the one that deploys. See docs/architecture/persistence-targets.md. When instantiating Hono, pass CloudflareBindings as the generic: new Hono<{ Bindings: CloudflareBindings }>().
There are two entry points, and which one a build uses is what decides
whether it has username/password sign-in. src/app.ts holds createApp(),
every route both builds serve; src/index.ts is what Cloudflare deploys
(wrangler.jsonc) and imports no password code at all; src/indexPassword.ts
(wrangler.mongo.jsonc) mounts it. A binding could not do this, bindings are
runtime values, so the handlers would be bundled either way, and
src/tests/routes/openapi.spec.ts fails if a password route reaches the
deployed entry. See docs/architecture/auth-modes.md.
Frontend (frontend/src)
Vue 3 + Ionic SPA. Pinia stores (composition-style, defineStore("id", () => ...)) hold app/UI state; TanStack Query handles remote server state. Persistent UI state is manually synced to localStorage inside store actions.
Bootstrapping (frontend/src/main.ts) order matters:
- If
VITE_MOCK === "true", start MSW (src/mocks/browser) and awaitworker.start()before anything else. - Create the Vue app and register router, Ionic, Pinia, VueQuery.
- Await
router.isReady(), then mount.
Tests also run through MSW with onUnhandledRequest: "error" (frontend/src/tests/setup.ts).
Auth flow (cookie-based JWT)
- Google OAuth handled by
@hono/oauth-providers/googleinbackend/src/routes/auth.ts. - Backend signs a JWT and stores it in an HTTP-only
session_tokencookie. /api/*routes, every version of them, are protected by Hono JWT middleware reading that cookie.- Frontend calls APIs with
credentials: "include"(frontend/src/services/api.ts) and fetches session user info from/api/v1/session.
Key conventions
- Shared packages:
dto/(API DTOs) andmodel/(domain models) are consumed by both frontend and backend: keep them framework-agnostic. - API design: follow
docs/development/api-naming-rules.md: plural nouns for collections,/api/v1/meandmy-prefix for self-scoped data (never takeplayerIdfrom the client), don't repeat path identifiers in request bodies, and resolve identity/authorization from the session/JWT server-side (hidingplayerIdfrom URLs is not a security control). The major version is a path segment,/api/v1and/internal/v1;/authis the OAuth handshake and is not versioned. Every route is described inbackend/openapi.yaml, which is hand-written and gated in both directions bybackend/src/tests/routes/openapi.spec.ts, adding a route without documenting it fails the suite. Seedocs/agents/openapi-spec.md. - Commits and versions: Conventional Commits, enforced by a
commit-msghook Gradle installs. Versions are computed from them by semantic-release on every push tomaster; never type a version number by hand. Seedocs/development/release-process.md. - npm script naming: use camelCase with no separators (
formatfix,lintfix), notformat:fixorformat_fix: Gradle's node plugin misinterprets:(subproject notation) and_(treated as a space). Seedocs/development/npm-script-naming.md. - Import alias: use
@/for frontendsrcimports (configured in Vite/tsconfig). - Temporal DTOs: backend responses carrying
@js-temporal/polyfillTemporal types must be explicitly deserialized on the frontend (Temporal.Instant.from,Temporal.Duration.from) in service-layer helpers. - Theming: use Ionic CSS vars from
frontend/src/theme/variables.css(e.g.--ion-color-wiki-gold); brand/UI tone guidance is inDESIGN.mdandPRODUCT.md. - Node/npm versions: enforced via
enginesin eachpackage.jsonand consumed by the Gradle node plugin (npmInstallCommand = "ci"). Keep versions aligned across root/frontend/backend when bumping. - Docs: grouped by concept under
docs/:domain/(game rules and entities),architecture/(code seams and layering),development/(working on the code),deployment/(shipping),adr/(numbered decisions),agents/(machine-read metadata; fixed paths, never move). Start fromdocs/README.md. Filenames are lowercase kebab-case (ADRs:NNNN-kebab-title.md); every doc carriestitle/type/tags/relatedfrontmatter and cross-links with relative markdown links. State a domain rule once indomain/and link to it, a doc that is both rule and implementation gets split in two and cross-linked. - Documentation site:
docs/: minusdocs/agents/, which stays machine-read metadata in the repo, is mirrored to https://fantasywiki.github.io/FantasyWiki/ bydocs/site/(VitePress), which adds an authored orientation layer, a docs-graph Atlas and a coverage board. Everything underdocs/site/build/is generated, never edit it. Before adding a page or a diagram, readdocs/agents/documentation-site.md; verify withcd docs/site && npm run build, whose dead-link and diagram checks are the gate.
Local development setup
Two gitignored env files are required (see docs/development/local-dev-setup.md for full details):
backend/.dev.vars:GOOGLE_CLIENT_SECRET,JWT_SECRET,FRONTEND_URL=localhost:5173frontend/.env.local:VITE_BACKEND_URL=http://127.0.0.1:8787,VITE_MOCK=true
With VITE_MOCK=true, MSW intercepts all /api/v1/* calls except /api/v1/session and /auth/*, which pass through to the real local backend (Wrangler on 127.0.0.1:8787).
Deployment
Branch-based deploys to Cloudflare (see docs/deployment/deploy-strategy.md):
master→ production Workerbackend, Pages projectfrontend, D1db, then a releasedev→ QA Workerbackend-preview, Pagesfrontend(dev branch), D1db-previewfeat/*andrenovate/*→ CI only (./gradlew check), no deploy
ci-cd.yml is the single entry point: a dispatcher job in it fans out to
check.yml (CI, all branches), coverage.yml, deploy.yml (deploy on
master/dev), release.yml (semantic-release, master only, after the
production deploy), docs.yml (only when docs/ changed) and
publish-images.yml. There is no dispatcher.yml or build.yml file.
Agent skills
Skills that read project configuration find it at fixed paths under
docs/agents/; do not move them. Which skills were used on this project, and
why, is in docs/development/ai-assistance.md.
Issue tracker
Issues are tracked in GitHub Issues for this repository. See docs/agents/issue-tracker.md.
Triage labels
Triage uses default labels: needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix. See docs/agents/triage-labels.md.
Domain docs
Domain docs use a single-context layout (CONTEXT.md + docs/adr/ at repo root). See docs/agents/domain.md.
OpenAPI spec
The HTTP contract is hand-written in backend/openapi.yaml and gated in both directions against the Worker's mounted route table by backend/src/tests/routes/openapi.spec.ts, adding a route without describing it fails the suite. Before writing one, read docs/agents/openapi-spec.md.
Documentation site
The technical documentation is published to GitHub Pages from docs/site/, which mirrors docs/ rather than copying it. Before adding a page, a diagram, or a section, read docs/agents/documentation-site.md, it carries the tier rules, the voice, the diagram palette, and the update checklist.