Imported from yglalpavir/wfls-tt-club (
AGENTS.md). Install upstream withnpx skills add yglalpavir/wfls-tt-club. Copyright stays with the author.
AGENTS.md — WFLS Table Tennis Club Website
Pure static site (HTML/CSS/JS, no build step, no test framework). Deployed on GitHub Pages.
Critical commands
After editing any content in data/{news,competitions,qa}/:
python tools/sync_content.py # Regenerate index.json + search.json + version history
python tools/sync_content.py --check # Dry-run validation only
Validate data integrity (score-log references, season coverage, JSON parse):
python tools/ci_validate.py
Regenerate the Assets manifest for the docs.html asset browser (auto-generated at deploy time; run manually for local dev, otherwise docs.html shows a load error):
python tools/gen_assets_manifest.py
No npm/lint/typecheck commands exist — there are no build tools or test suites.
Content system (news / competitions / QA)
- One folder per entry:
data/{type}/{id}/{id}.jsonis the single source of truth index.jsonandsearch.jsonare generated — never edit manually (sync_content.py --checkdiffs them against regenerated output and fails CI on mismatch)- Version snapshots (
{id}.v{n}.json) and history manifests ({id}.history.json) are auto-maintained bysync_content.py— and they are fetched at runtime by detail pages (version-history UI), so they must stay tracked and deployed - Setting
"visible": falsehides an entry without deleting it - Tag values are whitelisted (see README for exact lists)
- Date format:
YYYY-MM-DD(used for sort order) - Inline media markers:
{{media:N}}/{{media:mid}}incontentembed an item from that entry'smediaarray at that spot; themediaarray stays the single source of attachment files and is validated bysync_content.py(dangling refs warn). Unreferenced media still renders in the bottom gallery — old entries need no migration. Marker rendering lives injs/common.js(renderDetailBody/embedDetailMedia); keep the JS regex and the PythonMEDIA_REF_REin sync
Data flow
data/players.json→ single source for all player data (uid, initialScore, tags, honors, role)data/score-log.json→ match records + bonus adjustments, consumed byjs/score-engine.js- Doubles records (双打):
类型: "双打"matches carry胜者/负者as"甲/乙"pair strings (WTT-site pattern, pair-as-entity). Pair names are normalized at load (js/common.jsnormalizeDoublesPairName, members sorted by pinyin — "A/B" ≡ "B/A") and all URLs/details must use the in-memory canonical form. Singles pipelines filter doubles records OUT (isDoublesRecord), the doubles ranking runs on doubles-only records with pair initial scores (buildDoublesInitialScores= average of the two members' singles points on the pair's first match day; doubles is innoDecayTypes— no decay). Any computation outside the loaded page's own口径 must swap engine globals viawithScoreContext()(common.js) — engine caches key off thescoreLogDataarray reference. Python validators (ci_validate.py/append_submission.py) validate pairs member-by-member and compare self-play on the member set data/event-coefficient.json→ event type coefficients; reserved keys赛制系数and默认赛制are objects, not numbers — filter bytypeof v === 'number'when iteratingdata/seasons.json→ season definitions; CI fails if current date exceeds last endDate (must create new season)data/decay-config.json→ half-life and no-decay typesdata/draws.json→ tournament brackets (v3), rendered byjs/draws-viewer.js, edited viadraws-editor.htmldata/api/→ read-only public data API (rankings / snapshots / players / matches), generated at deploy time bytools/recompute_rankings.jsinside thedeployworkflow — gitignored, NOT committed (no merge conflicts with the deploy bot). Endpoints are documented bydata/api/index.html, itself generated. Local runs of the script are safe but produce ignored files.- Visitor submission pipeline (3 channels) → entrance button lives on
ranking.htmlunder the 积分计算规则 pill (sameqa-link-btnstyle). ① Tencent Docs form (no account needed; the ONLY visitor-visible entry onsubmit.html): owner marks an 审核 column, exports CSV,tools/import_submissions_csv.pyvalidates + appends locally (dedup state in gitignoredtools/.import-state.json); ② GitHub Issues:submit.htmlbuilds a prefilled issue (label提交), a maintainer applies审核通过,.github/workflows/submission-review.ymlparses it viatools/append_submission.pyand opens a PR — never push bot commits directly tomain; ③ QQ group fallback (copy JSON). The GitHub button and the whole manual-entry card are hidden behindENABLE_GITHUB_SUBMIT/ENABLE_MANUAL_ENTRYflags at the top ofsubmit.html— don't "fix" the missing UI, flip the flags. All channels share the same validity rules (viaappend_submission.py's functions) andci_validate.pystays the final gate. Score records may carry optional比分("3-1") /局分(["11-9", ...], winner-perspective) fields; the engine ignores unknown fields. WTT score-logs (wtt_data/*/score-log-*.json) support the same optional fields — rendered by match-detail (both modes), the wtt_ranking score-detail modal and the wtt_player match table (score column auto-appears when any row carries them), and validated byci_validate.pyunder the same rules (WTT records have no赛制, so the games-count-vs-format check is skipped for them).
Architecture gotchas
- No build step: all JS loads via
<script>tags; modules are global scope, not ES modules - Shared page chrome: navbar / footer / QR modal are injected from
js/shared-partials.js(<script src="js/shared-partials.js" data-partial="nav|footer|qr-modal">) — edit the template there, NOT per page. Active-link highlighting is applied at runtime byhighlightNavByPath();wtt_hub.html(custom chrome) is the only page exempt - Dual pipelines: club ranking (js/score-engine.js) and WTT ranking (js/wtt_common.js) are ~40% cloned code that has drifted — changes to scoring logic may need both
- Global mutable state: ~15 shared global variables (
scoreLogData,initialScoresData, etc.) across 5+ scripts; WTT pages should usewttWithDataContext()(try/finally-safe swap injs/wtt_common.js) — never hand-roll save/swap/restore - innerHTML everywhere:
escapeHtmlinjs/common.jsescapes& < > " '— inlineonclickhandlers built from player names are gone (event delegation +data-*attributes). Keep it that way; don't reintroduce interpolated inline handlers - Content loading: pages use
fetch()to load JSON at runtime;robots.txtmust NOT block/data/or SEO gets empty shells - Season expiration: if
seasons.jsonlast endDate passes, new matches silently extend the old season (no crash, but wrong data scope). Check withci_validate.py - Same-day duplicate match records are legit: identical
(日期, 类型, 胜者, 负者)rows inscore-log.jsonare real multiple games (README has the convention) — do NOT dedupe, andci_validate.pyintentionally has no duplicate detector - Draws shared core: bracket data model / layout geometry / validation / templates / serialization all live in
js/draws-core.js, shared by the spectator viewer (js/draws-viewer.js, used bydetail.html) and the visual editor (js/draws-editor.js+draws-editor.html, entry inadmin.html). Change geometry or the v3 schema THERE, not in either consumer.detail.htmlmust loaddraws-core.jsbeforedraws-viewer.js - Draws v2 backward compat: the viewer normalizes v2 data in memory (
dcNormalizeDraw) — don't assumedraws.jsoncards always havetypeor structured player objects;winnervalues 0/1/2 and card id uniqueness are enforced byci_validate.py
i18n
- Translation dictionary:
js/common.js→i18nobject withzhandenkeys - Language preference stored in localStorage (
wfls-lang.v1) - Some ranking/chart labels are still hardcoded Chinese — verify after edits
File structure quick reference
| Path | Purpose |
|---|---|
data/players.json |
Player profiles (single source of truth) |
data/score-log.json |
Match records + bonus adjustments |
data/seasons.json |
Season definitions (must cover current date) |
data/event-coefficient.json |
Event type coefficients |
data/decay-config.json |
Time decay config |
data/umpire-quiz.json |
Umpire-training easter-egg quiz (questions → videos in Assets/videos/umpire/) |
data/draws.json |
Tournament bracket data (v3: cards + connections + structured players) |
data/api/ |
Generated read-only data API (deploy-time only, gitignored) |
js/score-engine.js |
Club ranking calculation core |
js/wtt_common.js |
WTT data loading + ranking |
js/common.js |
i18n, global state, shared UI |
js/draws-core.js |
Draws shared core (v3 model, layout, validation, templates, serialization) |
js/draws-viewer.js |
Bracket spectator renderer (detail.html) |
js/draws-editor.js |
Bracket visual editor logic (draws-editor.html) |
tools/sync_content.py |
Content index generator (run after any content edit) |
tools/ci_validate.py |
Data integrity validator |
tools/recompute_rankings.js |
Generates data/api/ (runs the real score engine headless in a Node vm) |
tools/gen_assets_manifest.py |
Scans Assets/ into Assets/manifest.json (deploy-time generated, gitignored — same pattern as data/api/; run manually for local dev) |
docs.html |
网站文档/Docs: read-only file-manager view over Assets/ (breadcrumb nav, grid/list, type filter, search; previews image/svg/video/audio/pdf/text/code/markdown via modal) — data comes from Assets/manifest.json; entry lives in the navbar More… dropdown (nav_docs), page uses shared chrome + common.js i18n (docs_* keys) |
js/docs-browser.js |
docs.html logic: manifest-tree path validation (hash routes are never used to build URLs directly), event delegation, hash routing #/dir / #/dir/file, lazy marked.js load for Markdown preview; UI strings resolve via t() from common.js i18n with zh fallback, re-rendered by docsBrowserReapplyI18n() on language switch |
tools/migrate_draws_v3.py |
One-shot draws.json v2 → v3 migration (finished; kept locally only, gitignored) |
js/season-review.js |
Season review page logic (club + WTT dual mode, switched by window.SR_WTT_MODE) |
match.html / wtt_match.html |
Match-detail page for a single score-log record (WTT wrapper sets window.MD_WTT_MODE) |
js/match-detail.js |
Match-detail logic: 比分/局分, pre/post-match points, points breakdown, predicted win rate, H2H (club + WTT dual mode) |
docs/ |
tech/ long-lived technical docs · reports/ one-off prediction/audit reports · posters/ poster HTML sources |
submit.html |
Visitor match-record submission page (builds a prefilled GitHub issue) |
Assets/manifest.json |
Generated at deploy time by tools/gen_assets_manifest.py inside the deploy workflow — gitignored, NOT committed. docs.html fetches it; if missing (local dev), the page shows a hint to run the script |
tt_game/ |
3D table-tennis game easter egg (Three.js; UI reskinned with the site's design system). Homepage hero-ball links straight here (plain <a href> in index.html; the tt_game/umpire-training rotation script and its wfls-egg-last localStorage key were removed 2026-09-19 — umpire-training as a homepage egg is deferred, not deleted). tt_game/js/ is an active training workspace (weight .baks, logs) — game runtime files are the ones referenced by tt_game/index.html script tags. Selectable models live in js/input.js MODEL_NAMES + hardcoded buttons/options in index.html(极端对手/极端·满档 unselectable since 2026-09-19, but opponent-ladder.js levels stay — tools/train-input3.js curriculum depends on them); 「地狱AI克星」 is trained by tools/train-nemesis.js (GA, fitness = dual-orientation unbiased point-rate vs hell, ANSI live CLI panel) writing js/learned-policy-nemesis.js. The hell-AI guardrail口径 (moveSpeed 2.8/moveErr 0.03/moveZ 2.6) is intentionally duplicated in THREE places — policy.js strongVec, train-nemesis.js HELL_GUARD, train-nemesis-worker.js hellOpponent — keep them in sync or training/实机 drift apart |
.github/ISSUE_TEMPLATE/match-record.yml |
Issue form for submissions (auto-labels 提交) |
.github/workflows/submission-review.yml |
Turns 审核通过-labeled submission issues into PRs |
tools/append_submission.py |
Parses/validates issue-submitted records, appends to score-log.json |
tools/import_submissions_csv.py |
Imports Tencent Docs CSV exports into score-log.json (same validation rules) |
Tracked tree is deploy-facing only: .zcode/ / .opencode/ (AI session plans), .vscode/, .poster-preview/, one-off tools/ scripts and raw research data are all gitignored and exist only on local disks — never commit them.
Common mistakes to avoid
- Editing
index.jsonorsearch.jsondirectly — they get overwritten bysync_content.py(and--checknow fails CI on drift) - Adding a news/competition/QA entry without running
sync_content.py - Forgetting that
event-coefficient.jsonhas reserved object keys (赛制系数,默认赛制) mixed with numeric coefficient keys - Not checking season coverage after adding new matches — run
ci_validate.py - Building inline
onclickhandlers from data — use event delegation +data-*attributes - Changing scoring logic in only one pipeline (club vs WTT) — they're separate implementations; reuse
LOSER_POINT_MULTIPLIER/DECAY_HALF_LIFE_DAYSinstead of hardcoding coefficients. Decayed match points are per-side: always usecalcMatchPointsDual()— never derive the loser's decayed deduction from the winner's freeze weight (their 球员×类型 batches differ) - Editing nav/footer markup in individual HTML pages — it lives in
js/shared-partials.js - Deduplicating same-day repeated match records — they are intentional (multiple games per day)
- Committing
data/api/— it is generated at deploy time by thedeployworkflow and gitignored; committing it recreates merge conflicts with deployments - Letting the submission bot push to
maindirectly, or editingsubmit.html/append_submission.pyvalidation without keeping it in sync withci_validate.py— the three must agree on what a valid record is; PRs are the only ingest path (the retiredrecompute.ymlbot-commit workflow is the cautionary tale) - Hand-building match-detail URLs from raw score-log names — links must go through
buildMatchDetailUrl()with the in-memory (alias-normalized; WTT doubles pairs alphabetically re-sorted) names, tuple + same-day occurrencen; raw-file pair order won't match the normalized log. GetnfromcomputeMatchOccurrenceMap(localLog)(common.js, keyed by record object, same ordering asmdCompute) — every list page (ranking / player-page / data-viz / wtt_*) must pass it, or same-day duplicate matchups all open the first game - Mixing 口径 when computing with club doubles data present: singles timelines must run on the non-doubles log and doubles timelines on doubles-only records (bonus records are singles-only), each wrapped in
withScoreContext()— running the engine directly on the raw full log turns pairs into phantom ranking rows; also never build pair display/links from the raw"A/B"string withoutsplitPairNames()(order in the file is not canonical)
