Compare commits

...

2 Commits

Author SHA1 Message Date
khodowany 78b2c383f1 Game Features
ci / frontend (push) Successful in 9m13s
ci / backend (push) Failing after 6m40s
2026-10-04 23:20:06 -05:00
khodowany 2759337d16 Setup 2026-10-04 21:15:21 -05:00
80 changed files with 22627 additions and 167 deletions
+2
View File
@@ -0,0 +1,2 @@
# Hostname only, e.g. play.your-domain.example. DNS must point at the host.
FPOL_DOMAIN=localhost
+17
View File
@@ -56,3 +56,20 @@ jobs:
run: python3 modulus.py build --all
- name: Test
run: python3 modulus.py test --all
- uses: actions/setup-node@v4
with:
node-version: '24'
- name: Build frontend for browser checks
run: |
npm --prefix frontend ci
npm --prefix frontend run build
- uses: browser-actions/setup-chrome@v2
id: chrome
with:
chrome-version: stable
install-dependencies: true
no-sudo: true
- name: Browser smoke with real C++ server
env:
FPOL_CHROMIUM: ${{ steps.chrome.outputs.chrome-path }}
run: node tests/browser.mjs build/build_x86_debug/fpol_server
+1
View File
@@ -17,3 +17,4 @@ frontend/dist/
.env.*
!.env.example
*.local
var/
+28 -7
View File
@@ -8,25 +8,46 @@ set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
find_package(modulus CONFIG REQUIRED COMPONENTS boost_json openssl)
find_package(modulus CONFIG REQUIRED COMPONENTS boost_json openssl sqlite)
find_package(ligo CONFIG REQUIRED)
find_package(noto CONFIG REQUIRED)
find_package(Threads REQUIRED)
include(CheckCXXSourceCompiles)
function(fpol_check_ligo)
set(CMAKE_REQUIRED_LIBRARIES ligo::ligo)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
set(CMAKE_TRY_COMPILE_CONFIGURATION "${CMAKE_BUILD_TYPE}")
check_cxx_source_compiles("#include <ligo/http.hpp>
int main() { ligo::http::request request; request.headers[\"Accept\"] = \"application/json\"; decltype(&ligo::http::client::exchange) method = nullptr; (void)method; }" FPOL_HAS_LIGO_RAW_HTTP)
endfunction()
fpol_check_ligo()
if(NOT FPOL_HAS_LIGO_RAW_HTTP)
message(FATAL_ERROR "This app needs the updated Ligo HTTP API. Build with the sibling checkout using tools/build_workspace.py, or install the matching Ligo package revision.")
endif()
add_library(fpol_core src/domain.cpp src/store.cpp src/database.cpp src/crypto.cpp src/application.cpp src/game.cpp src/results.cpp)
target_link_libraries(fpol_core PUBLIC modulus::modulus modulus::boost_json noto::noto modulus::openssl Threads::Threads)
add_executable(fpol_admin src/admin.cpp)
target_link_libraries(fpol_admin PRIVATE fpol_core)
add_executable(fpol_server src/main.cpp src/server.cpp)
target_link_libraries(fpol_server PRIVATE modulus::modulus ligo::ligo Threads::Threads)
target_compile_options(fpol_server PRIVATE $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall;-Wextra;-Wpedantic>)
target_link_libraries(fpol_server PRIVATE fpol_core ligo::ligo)
add_executable(fpol_ingest src/ingest_main.cpp src/ingestion.cpp)
target_link_libraries(fpol_ingest PRIVATE fpol_core ligo::ligo)
foreach(target fpol_core fpol_admin fpol_server fpol_ingest)
target_compile_options(${target} PRIVATE $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall;-Wextra;-Wpedantic>)
endforeach()
if(BUILD_TESTING)
find_package(Python3 REQUIRED COMPONENTS Interpreter)
add_test(NAME product_fpol
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_server.py $<TARGET_FILE:fpol_server>
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_all.py $<TARGET_FILE:fpol_admin> $<TARGET_FILE:fpol_server> $<TARGET_FILE:fpol_ingest>
)
set_tests_properties(product_fpol PROPERTIES TIMEOUT 60)
set_tests_properties(product_fpol PROPERTIES TIMEOUT 240)
add_custom_target(check
COMMAND ${CMAKE_CTEST_COMMAND} --test-dir ${CMAKE_BINARY_DIR} -R ^product_fpol$ --output-on-failure
DEPENDS fpol_server
DEPENDS fpol_admin fpol_server fpol_ingest
USES_TERMINAL
)
endif()
install(TARGETS fpol_server RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(TARGETS fpol_server fpol_admin fpol_ingest RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
+71 -70
View File
@@ -1,78 +1,35 @@
# Fantasy Politics
`product-fpol` is a private, noncommercial fantasy-style game for friends around the 2026 US midterm elections. The eventual game will have a candidate draft, trades, and final standings based on real-world candidate performance.
A private fantasy election game for friends, built in C++23 with the shared **Modulus**, **Ligo**, and **Noto/SQLite** libraries. The frontend uses TypeScript and Vite.
This repository currently contains only the foundation: a basic webpage, a C++ HTTP server, and development tooling. There are no accounts, candidate records, league rules, drafts, trades, scores, or election data yet.
The app now has username/password accounts, invitation-based registration, private leagues, timed snake drafts, private queues, rosters, and provisional standings. The main navigation is **Leagues / Candidates / Results**. The earlier scoring simulator lives separately at `/lab.html`, with its APIs enabled only by `--allow-demo`.
## Prerequisites
**Real drafts remain locked.** The included official catalog has 115 candidates in 30 Michigan/North Carolina races. National candidate coverage, sourced ratings, and live count/outcome verification are still incomplete. League creation and invitations work before those data gates clear. There is no deployed site yet.
- Linux with Docker available to your user and Python 3 for the backend tooling.
- Node.js 22.12 or newer and npm for the frontend; CI uses Node 24.
- Network access on the first setup to fetch npm packages, the build image, and public Conan dependencies.
## Start locally
Modulus supplies the shared C++23/GCC 12 build environment, Conan/CMake integration, formatting, and editor configuration. Ligo supplies HTTP networking and routing. Both are consumed from the public khodo Conan registry; sibling source checkouts are not required. Package reads do not need registry credentials.
Use the sibling `modulus`, `noto`, and **updated `ligo`** checkouts in this workspace. This change extends Ligo's HTTP API; an older published `ligo/latest` package cannot build this app. The workspace helper exports the local recipes into your Conan cache and builds against those revisions, without publishing anything.
## Development
From the repository root, configure and build the backend:
```sh
python3 modulus.py configure
python3 modulus.py build
python3 modulus.py run
```
In another terminal, start the frontend:
```sh
cd frontend
npm ci
npm run dev
```
Open **http://127.0.0.1:5173**. Vite reloads frontend edits and proxies `/api` to **http://127.0.0.1:8080**. Rebuild and restart the backend after C++ changes. Both configured ports fail explicitly when occupied.
Check the backend directly or through Vite:
```sh
curl http://127.0.0.1:8080/api/health
curl http://127.0.0.1:5173/api/health
```
The only application API is `GET /api/health`, which returns HTTP 200 using Ligo's existing envelope:
```json
{"ok":true,"payload":{"status":"ok"}}
```
Unknown routes return HTTP 404 with Ligo's error envelope. Health is a process liveness check; there is no database or external data source to check yet.
## Built frontend
Build the frontend, then serve it with the C++ backend:
Prerequisites: a C++23 compiler (the configured target is GCC 12), CMake 3.25+, Ninja, Python 3, Conan 2.26.2, Node 22.12+, and network access for initial dependencies. CI uses Node 24.
```sh
conan profile detect # once, if you do not already have a profile
python3 tools/build_workspace.py
npm --prefix frontend ci
npm --prefix frontend run build
python3 modulus.py run -- --web-root frontend/dist
build/workspace_debug/fpol_admin import data/real/us-2026-10-04-partial.json
build/workspace_debug/fpol_admin create-user YOUR_USERNAME
# Password is prompted privately; use at least 12 characters.
build/workspace_debug/fpol_server --web-root frontend/dist
```
Open **http://127.0.0.1:8080**. Node is only needed to build the assets; the C++ server serves the resulting HTML, JavaScript, and CSS. Assets are loaded at startup, so restart the server after rebuilding. `npm --prefix frontend run preview` is also available on port 4173, with the same API proxy, for local frontend previews.
Open **http://127.0.0.1:8080**. The first account created through the local CLI is an administrator. Friends create accounts through private league invitations. All commands default to `var/fpol.sqlite`; use `--database PATH` consistently to select another database.
The server accepts `--host ADDRESS`, `--port PORT`, `--web-root DIRECTORY`, and `--help`. It defaults to `127.0.0.1:8080` and API-only mode. An explicit web root must contain `index.html`. Only exact registered asset paths are served; there is no SPA fallback or query-string normalization in this scaffold. Hidden files and symlinks are excluded, and `/api` is reserved. Use only the Vite build output as the web root.
For Vite development, start the backend with `--origin http://127.0.0.1:5173`, then run `npm --prefix frontend run dev`. Use the exact configured origin, including hostname and port. For Vite preview use origin `http://127.0.0.1:4173`. Cookie security defaults suit loopback development; production uses HTTPS and `--secure-cookies`.
The Modulus runner binds the server to `0.0.0.0` **inside its container**, publishing port 8080 only on the host's loopback interface. An installed executable defaults to loopback. Port `0` selects an available port and is useful for tests. SIGINT and SIGTERM stop the server cleanly.
## Checks and editor support
```sh
python3 modulus.py test
python3 modulus.py format --check
npm --prefix frontend run build
```
The frontend build includes strict TypeScript checking. `npm --prefix frontend run typecheck` runs that check alone. The backend CTest smoke suite starts real server processes to check health, routing, binary/static assets, startup errors, port conflicts, and shutdown.
Check both native configurations:
The existing Modulus workflow remains available once the matching Ligo package is in its Conan cache/remote:
```sh
python3 modulus.py configure --all
@@ -80,17 +37,61 @@ python3 modulus.py build --all
python3 modulus.py test --all
```
Configure generates ignored editor files. Customize `modulus.json`, not those generated files; refresh compile databases with `python3 modulus.py lsp`. C++ and Python use the shared Modulus formatting policy and full names rather than aliases. Run `python3 modulus.py format` before committing.
`modulus.py` remains byte-identical to the canonical bootstrap. Do not use `configure --update` to replace the local Ligo revision with an older published API. The [deployment guide](docs/deployment.md) describes a source-based Compose build that includes all four checkouts.
The checked-in `modulus.py` is copied unchanged from Modulus's canonical `modulus/bootstrap.py`. `configure --update` refreshes shared tooling and packages. `--no-remote` on configure uses only the project-local Conan cache, which must already contain dependencies. Keep build outputs and downloaded tooling untracked.
## Try a complete practice draft
The Gitea workflow checks the frontend build plus backend formatting, debug/release builds, and tests. It has no publishing or deployment job. Its direct build mode provisions the shared compiler and tools on the CI runner, following the sibling repositories' convention. CMake installation installs the backend executable; distribute built frontend assets separately when deployment is added.
```sh
build/workspace_debug/fpol_admin import data/demo/catalog.json data/demo/ratings.json \
data/demo/results-0.json data/demo/results-1.json data/demo/results-2.json \
data/demo/results-3.json data/demo/results-4.json
build/workspace_debug/fpol_server --web-root frontend/dist --allow-demo
```
## Future product work
Create a league and choose the **Practice** candidate pool. Invite a second player, then start from the draft room. Demo data stays explicitly separate from real data. The legacy simulator replays its five synthetic snapshots; those old snapshots are not automatically published as live league results.
- Define league rules, eligible races and candidates, drafting, trades, and scoring together before introducing domain models.
- Add persistence when needed; Noto is the existing SQLite library to evaluate then.
- Choose an election data source later. Live feeds, scraping, update schedules, and correction handling remain undecided; this scaffold makes no external election requests.
- Add accounts, invitations, deployment, and operational requirements when the game needs them.
## Rules
Keep reusable networking and infrastructure in the shared libraries and product rules in this repository. Luma and Sonus are native UI/audio libraries and are not dependencies of this web application.
Leagues have 2–12 teams and 12 snake-draft rounds. Every team has Democrat, Republican, Other, and Flex slots for each of House, Senate, and Governor. A pick fills its party slot before Flex. Candidates are exclusive within a league; legal-pick checks preserve enough candidates for every remaining roster slot.
The server owns the 120-second clock. A timeout takes the first legal private-queue candidate, otherwise a uniformly random legal candidate. Commissioners can pause/resume. A process restart pauses active drafts; resuming gives the current picker a fresh 120 seconds. Browser draft views poll every 2 seconds; results and standings poll every 15 seconds.
`(November first-round / first-choice vote share percentage + 50 for the eventual called/certified winner) × frozen race rating`
Safe ×1, Likely ×1.1, Lean ×1.25, Toss-up ×1.5. Each candidate is rounded half up to two decimals before summing. Counts and outcomes are independent: a runoff changes the winner bonus while November share stays intact. Call retractions remove the bonus; corrections can lower points. Missing counts/ratings are unknown, not zero. Provisional rankings show reporting coverage beside known subtotals. Catalog, ratings, order, and rules freeze at draft start. Trades and waivers remain a later milestone.
## Election data and operations
The C++ `fpol_ingest` worker supports reviewed Open America count mappings, a normalized official-source JSON contract, candidate-source change capture, and a civicAPI capture/probe path. **civicAPI normalization is not implemented until a real response/schema has been validated.** No feed is configured or enabled automatically.
Changed raw payloads and normalized observations are immutable. Failed/ambiguous responses are quarantined; the last good counts remain visible with stale provenance. Replaying a file cannot verify a source or publish to a real catalog. Operators must obtain a successful live capture, review mappings, and explicitly activate coverage.
See [ingestion and source contracts](docs/ingestion.md), [source coverage](docs/data-coverage.md), the [506-contest inventory](data/contests_2026.json), and the [state/office coverage matrix](data/coverage_2026.csv). Official lists can change; review again before freezing. `tools/generate_real_catalog.py` reproduces the reviewed NC import and combined catalog; it does not fetch fresh evidence or claim a new review date.
Password recovery is operator assisted:
```sh
build/workspace_debug/fpol_admin reset-link USERNAME
```
Give that user `https://YOUR_HOST/#reset/TOKEN` using the returned token. It expires in 30 minutes, works once, and revokes all existing sessions on use. There is no email service. See [the API contract](docs/application-api.md) for session, CSRF, and request details.
## Verify
```sh
python3 tools/build_workspace.py --test
npm --prefix frontend run build
node tests/browser.mjs build/workspace_debug/fpol_server
python3 modulus.py format --check
```
The browser smoke test uses installed Chromium (`FPOL_CHROMIUM` can select a binary), Node's standard library, two isolated browser sessions, and the real C++ server. Screenshots go to ignored `build/browser-artifacts/`.
Socket-independent checks can be run separately:
```sh
python3 tests/test_core.py build/workspace_debug/fpol_admin
python3 tests/test_application.py build/workspace_debug/fpol_admin build/workspace_debug/fpol_ingest
```
They do not replace HTTP/browser checks. [The verification record](docs/verification.md) states exactly what ran here and what remains blocked. [Deployment and backup instructions](docs/deployment.md) cover persistent storage, Caddy HTTPS, bootstrap accounts, online backups, and restore.
+44
View File
@@ -0,0 +1,44 @@
name: product-fpol
services:
app:
image: product-fpol:local
build:
context: ..
dockerfile: product-fpol/deploy/Dockerfile
command: [fpol_server, --host, 0.0.0.0, --database, /data/fpol.sqlite, --web-root, /app/web, --origin, 'https://${FPOL_DOMAIN:?Set FPOL_DOMAIN in .env}', --secure-cookies]
volumes: [fpol_data:/data]
restart: unless-stopped
init: true
healthcheck:
test: [CMD, curl, --fail, --silent, http://127.0.0.1:8080/api/health]
interval: 20s
timeout: 5s
retries: 3
networks: [private]
ingest:
image: product-fpol:local
command: [fpol_ingest, --database, /data/fpol.sqlite, run]
volumes: [fpol_data:/data]
restart: unless-stopped
init: true
depends_on:
app:
condition: service_healthy
networks: [private]
caddy:
image: caddy:2
environment:
FPOL_DOMAIN: '${FPOL_DOMAIN:?Set FPOL_DOMAIN in .env}'
ports: ['80:80', '443:443', '443:443/udp']
volumes: ['./deploy/Caddyfile:/etc/caddy/Caddyfile:ro', 'caddy_data:/data', 'caddy_config:/config']
restart: unless-stopped
depends_on:
app:
condition: service_healthy
networks: [private]
networks:
private:
volumes:
fpol_data:
caddy_data:
caddy_config:
+5 -2
View File
@@ -5,7 +5,7 @@ import conan.tools.cmake
class product_fpol_conan(conan.ConanFile):
name = "product_fpol"
version = "latest"
description = "Fantasy Politics web application scaffold"
description = "Private fantasy election leagues with C++ services"
package_type = "application"
settings = "os", "arch", "compiler", "build_type"
options = {"with_tests": [True, False]}
@@ -17,10 +17,13 @@ class product_fpol_conan(conan.ConanFile):
"src/*.cpp",
"src/*.hpp",
"tests/*.py",
"data/demo/*.json",
"data/real/*.json",
)
def requirements(self):
self.requires(str(self.python_requires["modulus"].ref), transitive_headers=True, transitive_libs=True, options={"with_openssl": True})
self.requires(str(self.python_requires["modulus"].ref), transitive_headers=True, transitive_libs=True, options={"with_openssl": True, "with_sqlite": True})
self.requires("ligo/latest", transitive_headers=True, transitive_libs=True)
self.requires("noto/latest", transitive_headers=True, transitive_libs=True)
def generate(self):
dependencies = conan.tools.cmake.CMakeDeps(self)
dependencies.generate()
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
state,office,scheduled_2026,catalog,ratings,civicapi_results,civicapi_calls,openamerica_results,openamerica_calls,checked_date
AL,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
AL,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
AL,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
AK,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
AK,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
AK,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
AZ,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
AZ,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
AZ,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
AR,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
AR,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
AR,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
CA,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
CA,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
CA,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
CO,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
CO,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
CO,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
CT,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
CT,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
CT,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
DE,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
DE,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
DE,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
FL,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
FL,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
FL,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
GA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
GA,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
GA,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
HI,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
HI,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
HI,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
ID,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
ID,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
ID,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
IL,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
IL,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
IL,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
IN,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
IN,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
IN,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
IA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
IA,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
IA,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
KS,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
KS,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
KS,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
KY,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
KY,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
KY,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
LA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
LA,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
LA,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
ME,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
ME,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
ME,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
MD,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
MD,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
MD,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
MA,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
MA,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
MA,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
MI,house,yes,verified_partial_import,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
MI,senate,yes,verified_partial_import,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
MI,governor,yes,verified_partial_import,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
MN,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
MN,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
MN,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
MS,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
MS,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
MS,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
MO,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
MO,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
MO,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
MT,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
MT,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
MT,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
NE,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NE,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NE,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NV,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NV,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
NV,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NH,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NH,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NH,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NJ,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NJ,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NJ,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
NM,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NM,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NM,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NY,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NY,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
NY,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
NC,house,yes,official_partial,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
NC,senate,yes,official_partial,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
NC,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
ND,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
ND,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
ND,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
OH,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
OH,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
OH,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
OK,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
OK,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
OK,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
OR,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
OR,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
OR,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
PA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
PA,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
PA,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
RI,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
RI,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
RI,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
SC,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
SC,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
SC,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
SD,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
SD,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
SD,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
TN,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
TN,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
TN,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
TX,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
TX,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
TX,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
UT,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
UT,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
UT,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
VT,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
VT,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
VT,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
VA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
VA,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
VA,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
WA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
WA,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
WA,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
WV,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
WV,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04
WV,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
WI,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
WI,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04
WI,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
WY,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
WY,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
WY,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04
1 state office scheduled_2026 catalog ratings civicapi_results civicapi_calls openamerica_results openamerica_calls checked_date
2 AL house yes missing missing unverified unverified missing missing 2026-10-04
3 AL senate yes missing missing unverified unverified missing missing 2026-10-04
4 AL governor yes missing missing unverified unverified missing missing 2026-10-04
5 AK house yes missing missing unverified unverified missing missing 2026-10-04
6 AK senate yes missing missing unverified unverified missing missing 2026-10-04
7 AK governor yes missing missing unverified unverified missing missing 2026-10-04
8 AZ house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
9 AZ senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
10 AZ governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
11 AR house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
12 AR senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
13 AR governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
14 CA house yes missing missing unverified unverified missing missing 2026-10-04
15 CA senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
16 CA governor yes missing missing unverified unverified missing missing 2026-10-04
17 CO house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
18 CO senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
19 CO governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
20 CT house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
21 CT senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
22 CT governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
23 DE house yes missing missing unverified unverified missing missing 2026-10-04
24 DE senate yes missing missing unverified unverified missing missing 2026-10-04
25 DE governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
26 FL house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
27 FL senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
28 FL governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
29 GA house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
30 GA senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
31 GA governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
32 HI house yes missing missing unverified unverified missing missing 2026-10-04
33 HI senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
34 HI governor yes missing missing unverified unverified missing missing 2026-10-04
35 ID house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
36 ID senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
37 ID governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
38 IL house yes missing missing unverified unverified missing missing 2026-10-04
39 IL senate yes missing missing unverified unverified missing missing 2026-10-04
40 IL governor yes missing missing unverified unverified missing missing 2026-10-04
41 IN house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
42 IN senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
43 IN governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
44 IA house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
45 IA senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
46 IA governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
47 KS house yes missing missing unverified unverified missing missing 2026-10-04
48 KS senate yes missing missing unverified unverified missing missing 2026-10-04
49 KS governor yes missing missing unverified unverified missing missing 2026-10-04
50 KY house yes missing missing unverified unverified missing missing 2026-10-04
51 KY senate yes missing missing unverified unverified missing missing 2026-10-04
52 KY governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
53 LA house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
54 LA senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
55 LA governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
56 ME house yes missing missing unverified unverified missing missing 2026-10-04
57 ME senate yes missing missing unverified unverified missing missing 2026-10-04
58 ME governor yes missing missing unverified unverified missing missing 2026-10-04
59 MD house yes missing missing unverified unverified missing missing 2026-10-04
60 MD senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
61 MD governor yes missing missing unverified unverified missing missing 2026-10-04
62 MA house yes missing missing unverified unverified missing missing 2026-10-04
63 MA senate yes missing missing unverified unverified missing missing 2026-10-04
64 MA governor yes missing missing unverified unverified missing missing 2026-10-04
65 MI house yes verified_partial_import missing unverified unverified documented_state_unverified_office missing 2026-10-04
66 MI senate yes verified_partial_import missing unverified unverified documented_state_unverified_office missing 2026-10-04
67 MI governor yes verified_partial_import missing unverified unverified documented_state_unverified_office missing 2026-10-04
68 MN house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
69 MN senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
70 MN governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
71 MS house yes missing missing unverified unverified missing missing 2026-10-04
72 MS senate yes missing missing unverified unverified missing missing 2026-10-04
73 MS governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
74 MO house yes missing missing unverified unverified missing missing 2026-10-04
75 MO senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
76 MO governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
77 MT house yes missing missing unverified unverified missing missing 2026-10-04
78 MT senate yes missing missing unverified unverified missing missing 2026-10-04
79 MT governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
80 NE house yes missing missing unverified unverified missing missing 2026-10-04
81 NE senate yes missing missing unverified unverified missing missing 2026-10-04
82 NE governor yes missing missing unverified unverified missing missing 2026-10-04
83 NV house yes missing missing unverified unverified missing missing 2026-10-04
84 NV senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
85 NV governor yes missing missing unverified unverified missing missing 2026-10-04
86 NH house yes missing missing unverified unverified missing missing 2026-10-04
87 NH senate yes missing missing unverified unverified missing missing 2026-10-04
88 NH governor yes missing missing unverified unverified missing missing 2026-10-04
89 NJ house yes missing missing unverified unverified missing missing 2026-10-04
90 NJ senate yes missing missing unverified unverified missing missing 2026-10-04
91 NJ governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
92 NM house yes missing missing unverified unverified missing missing 2026-10-04
93 NM senate yes missing missing unverified unverified missing missing 2026-10-04
94 NM governor yes missing missing unverified unverified missing missing 2026-10-04
95 NY house yes missing missing unverified unverified missing missing 2026-10-04
96 NY senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
97 NY governor yes missing missing unverified unverified missing missing 2026-10-04
98 NC house yes official_partial missing unverified unverified documented_state_unverified_office missing 2026-10-04
99 NC senate yes official_partial missing unverified unverified documented_state_unverified_office missing 2026-10-04
100 NC governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
101 ND house yes missing missing unverified unverified missing missing 2026-10-04
102 ND senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
103 ND governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
104 OH house yes missing missing unverified unverified missing missing 2026-10-04
105 OH senate yes missing missing unverified unverified missing missing 2026-10-04
106 OH governor yes missing missing unverified unverified missing missing 2026-10-04
107 OK house yes missing missing unverified unverified missing missing 2026-10-04
108 OK senate yes missing missing unverified unverified missing missing 2026-10-04
109 OK governor yes missing missing unverified unverified missing missing 2026-10-04
110 OR house yes missing missing unverified unverified missing missing 2026-10-04
111 OR senate yes missing missing unverified unverified missing missing 2026-10-04
112 OR governor yes missing missing unverified unverified missing missing 2026-10-04
113 PA house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
114 PA senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
115 PA governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
116 RI house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
117 RI senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
118 RI governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
119 SC house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
120 SC senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
121 SC governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
122 SD house yes missing missing unverified unverified missing missing 2026-10-04
123 SD senate yes missing missing unverified unverified missing missing 2026-10-04
124 SD governor yes missing missing unverified unverified missing missing 2026-10-04
125 TN house yes missing missing unverified unverified missing missing 2026-10-04
126 TN senate yes missing missing unverified unverified missing missing 2026-10-04
127 TN governor yes missing missing unverified unverified missing missing 2026-10-04
128 TX house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
129 TX senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
130 TX governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
131 UT house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
132 UT senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
133 UT governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
134 VT house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
135 VT senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
136 VT governor yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
137 VA house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
138 VA senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
139 VA governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
140 WA house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
141 WA senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
142 WA governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
143 WV house yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
144 WV senate yes missing missing unverified unverified documented_state_unverified_office missing 2026-10-04
145 WV governor no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
146 WI house yes missing missing unverified unverified missing missing 2026-10-04
147 WI senate no not_applicable not_applicable not_applicable not_applicable not_applicable not_applicable 2026-10-04
148 WI governor yes missing missing unverified unverified missing missing 2026-10-04
149 WY house yes missing missing unverified unverified missing missing 2026-10-04
150 WY senate yes missing missing unverified unverified missing missing 2026-10-04
151 WY governor yes missing missing unverified unverified missing missing 2026-10-04
+4
View File
@@ -0,0 +1,4 @@
{
"catalog_id": "demo-2026",
"players": 12
}
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
{
"catalog_id": "demo-2026",
"ratings_id": "demo-ratings-v1",
"results_id": "demo-results-3",
"roster": [
{
"slot": "house:democrat",
"candidate_id": "demo-house-04-1"
},
{
"slot": "house:republican",
"candidate_id": "demo-house-01-2"
},
{
"slot": "house:other",
"candidate_id": "demo-house-02-3"
},
{
"slot": "house:flex",
"candidate_id": "demo-house-03-4"
},
{
"slot": "senate:democrat",
"candidate_id": "demo-senate-04-1"
},
{
"slot": "senate:republican",
"candidate_id": "demo-senate-01-2"
},
{
"slot": "senate:other",
"candidate_id": "demo-senate-02-3"
},
{
"slot": "senate:flex",
"candidate_id": "demo-senate-03-4"
},
{
"slot": "governor:democrat",
"candidate_id": "demo-governor-04-1"
},
{
"slot": "governor:republican",
"candidate_id": "demo-governor-01-2"
},
{
"slot": "governor:other",
"candidate_id": "demo-governor-02-3"
},
{
"slot": "governor:flex",
"candidate_id": "demo-governor-03-4"
}
]
}
+160
View File
@@ -0,0 +1,160 @@
{
"schema_version": 1,
"id": "demo-ratings-v1",
"kind": "ratings",
"mode": "demo",
"label": "Frozen fictional ratings",
"as_of": "2026-10-02T00:00:00Z",
"source": {
"name": "Fictional scoring fixtures",
"url": "fixture:product-fpol/demo",
"retrieved_at": "2026-10-01T00:00:00Z"
},
"catalog_id": "demo-2026",
"entries": [
{
"race_id": "demo-house-01",
"rating": "safe"
},
{
"race_id": "demo-house-02",
"rating": "likely"
},
{
"race_id": "demo-house-03",
"rating": "lean"
},
{
"race_id": "demo-house-04",
"rating": "toss_up"
},
{
"race_id": "demo-house-05",
"rating": "safe"
},
{
"race_id": "demo-house-06",
"rating": "likely"
},
{
"race_id": "demo-house-07",
"rating": "lean"
},
{
"race_id": "demo-house-08",
"rating": "toss_up"
},
{
"race_id": "demo-house-09",
"rating": "safe"
},
{
"race_id": "demo-house-10",
"rating": "likely"
},
{
"race_id": "demo-house-11",
"rating": "lean"
},
{
"race_id": "demo-house-12",
"rating": "toss_up"
},
{
"race_id": "demo-senate-01",
"rating": "safe"
},
{
"race_id": "demo-senate-02",
"rating": "likely"
},
{
"race_id": "demo-senate-03",
"rating": "lean"
},
{
"race_id": "demo-senate-04",
"rating": "toss_up"
},
{
"race_id": "demo-senate-05",
"rating": "safe"
},
{
"race_id": "demo-senate-06",
"rating": "likely"
},
{
"race_id": "demo-senate-07",
"rating": "lean"
},
{
"race_id": "demo-senate-08",
"rating": "toss_up"
},
{
"race_id": "demo-senate-09",
"rating": "safe"
},
{
"race_id": "demo-senate-10",
"rating": "likely"
},
{
"race_id": "demo-senate-11",
"rating": "lean"
},
{
"race_id": "demo-senate-12",
"rating": "toss_up"
},
{
"race_id": "demo-governor-01",
"rating": "safe"
},
{
"race_id": "demo-governor-02",
"rating": "likely"
},
{
"race_id": "demo-governor-03",
"rating": "lean"
},
{
"race_id": "demo-governor-04",
"rating": "toss_up"
},
{
"race_id": "demo-governor-05",
"rating": "safe"
},
{
"race_id": "demo-governor-06",
"rating": "likely"
},
{
"race_id": "demo-governor-07",
"rating": "lean"
},
{
"race_id": "demo-governor-08",
"rating": "toss_up"
},
{
"race_id": "demo-governor-09",
"rating": "safe"
},
{
"race_id": "demo-governor-10",
"rating": "likely"
},
{
"race_id": "demo-governor-11",
"rating": "lean"
},
{
"race_id": "demo-governor-12",
"rating": "toss_up"
}
]
}
+916
View File
@@ -0,0 +1,916 @@
{
"schema_version": 1,
"id": "demo-results-0",
"kind": "results",
"mode": "demo",
"label": "Synthetic replay 1: No votes yet",
"as_of": "2026-11-04T00:00:00Z",
"source": {
"name": "Fictional scoring fixtures",
"url": "fixture:product-fpol/demo",
"retrieved_at": "2026-10-01T00:00:00Z"
},
"catalog_id": "demo-2026",
"entries": [
{
"race_id": "demo-house-01",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-01-1",
"votes": 0
},
{
"candidate_id": "demo-house-01-2",
"votes": 0
},
{
"candidate_id": "demo-house-01-3",
"votes": 0
},
{
"candidate_id": "demo-house-01-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-02",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-02-1",
"votes": 0
},
{
"candidate_id": "demo-house-02-2",
"votes": 0
},
{
"candidate_id": "demo-house-02-3",
"votes": 0
},
{
"candidate_id": "demo-house-02-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-03",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-03-1",
"votes": 0
},
{
"candidate_id": "demo-house-03-2",
"votes": 0
},
{
"candidate_id": "demo-house-03-3",
"votes": 0
},
{
"candidate_id": "demo-house-03-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-04",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-04-1",
"votes": 0
},
{
"candidate_id": "demo-house-04-2",
"votes": 0
},
{
"candidate_id": "demo-house-04-3",
"votes": 0
},
{
"candidate_id": "demo-house-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-05",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-05-1",
"votes": 0
},
{
"candidate_id": "demo-house-05-2",
"votes": 0
},
{
"candidate_id": "demo-house-05-3",
"votes": 0
},
{
"candidate_id": "demo-house-05-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-06",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-06-1",
"votes": 0
},
{
"candidate_id": "demo-house-06-2",
"votes": 0
},
{
"candidate_id": "demo-house-06-3",
"votes": 0
},
{
"candidate_id": "demo-house-06-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-07",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-07-1",
"votes": 0
},
{
"candidate_id": "demo-house-07-2",
"votes": 0
},
{
"candidate_id": "demo-house-07-3",
"votes": 0
},
{
"candidate_id": "demo-house-07-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-08",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-08-1",
"votes": 0
},
{
"candidate_id": "demo-house-08-2",
"votes": 0
},
{
"candidate_id": "demo-house-08-3",
"votes": 0
},
{
"candidate_id": "demo-house-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-09",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-09-1",
"votes": 0
},
{
"candidate_id": "demo-house-09-2",
"votes": 0
},
{
"candidate_id": "demo-house-09-3",
"votes": 0
},
{
"candidate_id": "demo-house-09-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-10",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-10-1",
"votes": 0
},
{
"candidate_id": "demo-house-10-2",
"votes": 0
},
{
"candidate_id": "demo-house-10-3",
"votes": 0
},
{
"candidate_id": "demo-house-10-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-11",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-11-1",
"votes": 0
},
{
"candidate_id": "demo-house-11-2",
"votes": 0
},
{
"candidate_id": "demo-house-11-3",
"votes": 0
},
{
"candidate_id": "demo-house-11-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-12",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-12-1",
"votes": 0
},
{
"candidate_id": "demo-house-12-2",
"votes": 0
},
{
"candidate_id": "demo-house-12-3",
"votes": 0
},
{
"candidate_id": "demo-house-12-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-01",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-01-1",
"votes": 0
},
{
"candidate_id": "demo-senate-01-2",
"votes": 0
},
{
"candidate_id": "demo-senate-01-3",
"votes": 0
},
{
"candidate_id": "demo-senate-01-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-02",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-02-1",
"votes": 0
},
{
"candidate_id": "demo-senate-02-2",
"votes": 0
},
{
"candidate_id": "demo-senate-02-3",
"votes": 0
},
{
"candidate_id": "demo-senate-02-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-03",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-03-1",
"votes": 0
},
{
"candidate_id": "demo-senate-03-2",
"votes": 0
},
{
"candidate_id": "demo-senate-03-3",
"votes": 0
},
{
"candidate_id": "demo-senate-03-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-04",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-04-1",
"votes": 0
},
{
"candidate_id": "demo-senate-04-2",
"votes": 0
},
{
"candidate_id": "demo-senate-04-3",
"votes": 0
},
{
"candidate_id": "demo-senate-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-05",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-05-1",
"votes": 0
},
{
"candidate_id": "demo-senate-05-2",
"votes": 0
},
{
"candidate_id": "demo-senate-05-3",
"votes": 0
},
{
"candidate_id": "demo-senate-05-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-06",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-06-1",
"votes": 0
},
{
"candidate_id": "demo-senate-06-2",
"votes": 0
},
{
"candidate_id": "demo-senate-06-3",
"votes": 0
},
{
"candidate_id": "demo-senate-06-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-07",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-07-1",
"votes": 0
},
{
"candidate_id": "demo-senate-07-2",
"votes": 0
},
{
"candidate_id": "demo-senate-07-3",
"votes": 0
},
{
"candidate_id": "demo-senate-07-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-08",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-08-1",
"votes": 0
},
{
"candidate_id": "demo-senate-08-2",
"votes": 0
},
{
"candidate_id": "demo-senate-08-3",
"votes": 0
},
{
"candidate_id": "demo-senate-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-09",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-09-1",
"votes": 0
},
{
"candidate_id": "demo-senate-09-2",
"votes": 0
},
{
"candidate_id": "demo-senate-09-3",
"votes": 0
},
{
"candidate_id": "demo-senate-09-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-10",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-10-1",
"votes": 0
},
{
"candidate_id": "demo-senate-10-2",
"votes": 0
},
{
"candidate_id": "demo-senate-10-3",
"votes": 0
},
{
"candidate_id": "demo-senate-10-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-11",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-11-1",
"votes": 0
},
{
"candidate_id": "demo-senate-11-2",
"votes": 0
},
{
"candidate_id": "demo-senate-11-3",
"votes": 0
},
{
"candidate_id": "demo-senate-11-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-12",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-12-1",
"votes": 0
},
{
"candidate_id": "demo-senate-12-2",
"votes": 0
},
{
"candidate_id": "demo-senate-12-3",
"votes": 0
},
{
"candidate_id": "demo-senate-12-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-01",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-01-1",
"votes": 0
},
{
"candidate_id": "demo-governor-01-2",
"votes": 0
},
{
"candidate_id": "demo-governor-01-3",
"votes": 0
},
{
"candidate_id": "demo-governor-01-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-02",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-02-1",
"votes": 0
},
{
"candidate_id": "demo-governor-02-2",
"votes": 0
},
{
"candidate_id": "demo-governor-02-3",
"votes": 0
},
{
"candidate_id": "demo-governor-02-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-03",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-03-1",
"votes": 0
},
{
"candidate_id": "demo-governor-03-2",
"votes": 0
},
{
"candidate_id": "demo-governor-03-3",
"votes": 0
},
{
"candidate_id": "demo-governor-03-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-04",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-04-1",
"votes": 0
},
{
"candidate_id": "demo-governor-04-2",
"votes": 0
},
{
"candidate_id": "demo-governor-04-3",
"votes": 0
},
{
"candidate_id": "demo-governor-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-05",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-05-1",
"votes": 0
},
{
"candidate_id": "demo-governor-05-2",
"votes": 0
},
{
"candidate_id": "demo-governor-05-3",
"votes": 0
},
{
"candidate_id": "demo-governor-05-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-06",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-06-1",
"votes": 0
},
{
"candidate_id": "demo-governor-06-2",
"votes": 0
},
{
"candidate_id": "demo-governor-06-3",
"votes": 0
},
{
"candidate_id": "demo-governor-06-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-07",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-07-1",
"votes": 0
},
{
"candidate_id": "demo-governor-07-2",
"votes": 0
},
{
"candidate_id": "demo-governor-07-3",
"votes": 0
},
{
"candidate_id": "demo-governor-07-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-08",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-08-1",
"votes": 0
},
{
"candidate_id": "demo-governor-08-2",
"votes": 0
},
{
"candidate_id": "demo-governor-08-3",
"votes": 0
},
{
"candidate_id": "demo-governor-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-09",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-09-1",
"votes": 0
},
{
"candidate_id": "demo-governor-09-2",
"votes": 0
},
{
"candidate_id": "demo-governor-09-3",
"votes": 0
},
{
"candidate_id": "demo-governor-09-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-10",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-10-1",
"votes": 0
},
{
"candidate_id": "demo-governor-10-2",
"votes": 0
},
{
"candidate_id": "demo-governor-10-3",
"votes": 0
},
{
"candidate_id": "demo-governor-10-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-11",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-11-1",
"votes": 0
},
{
"candidate_id": "demo-governor-11-2",
"votes": 0
},
{
"candidate_id": "demo-governor-11-3",
"votes": 0
},
{
"candidate_id": "demo-governor-11-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-12",
"round": "general",
"status": "reporting",
"total_votes": 0,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-12-1",
"votes": 0
},
{
"candidate_id": "demo-governor-12-2",
"votes": 0
},
{
"candidate_id": "demo-governor-12-3",
"votes": 0
},
{
"candidate_id": "demo-governor-12-4",
"votes": 0
}
]
}
]
}
+916
View File
@@ -0,0 +1,916 @@
{
"schema_version": 1,
"id": "demo-results-1",
"kind": "results",
"mode": "demo",
"label": "Synthetic replay 2: Early reporting",
"as_of": "2026-11-04T01:00:00Z",
"source": {
"name": "Fictional scoring fixtures",
"url": "fixture:product-fpol/demo",
"retrieved_at": "2026-10-01T00:00:00Z"
},
"catalog_id": "demo-2026",
"entries": [
{
"race_id": "demo-house-01",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-01-1",
"votes": 800
},
{
"candidate_id": "demo-house-01-2",
"votes": 150
},
{
"candidate_id": "demo-house-01-3",
"votes": 30
},
{
"candidate_id": "demo-house-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-02",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-02-1",
"votes": 600
},
{
"candidate_id": "demo-house-02-2",
"votes": 300
},
{
"candidate_id": "demo-house-02-3",
"votes": 70
},
{
"candidate_id": "demo-house-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-03",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-03-1",
"votes": 540
},
{
"candidate_id": "demo-house-03-2",
"votes": 420
},
{
"candidate_id": "demo-house-03-3",
"votes": 25
},
{
"candidate_id": "demo-house-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-04",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-04-1",
"votes": 510
},
{
"candidate_id": "demo-house-04-2",
"votes": 490
},
{
"candidate_id": "demo-house-04-3",
"votes": 0
},
{
"candidate_id": "demo-house-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-05",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-05-1",
"votes": 800
},
{
"candidate_id": "demo-house-05-2",
"votes": 150
},
{
"candidate_id": "demo-house-05-3",
"votes": 30
},
{
"candidate_id": "demo-house-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-06",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-06-1",
"votes": 600
},
{
"candidate_id": "demo-house-06-2",
"votes": 300
},
{
"candidate_id": "demo-house-06-3",
"votes": 70
},
{
"candidate_id": "demo-house-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-07",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-07-1",
"votes": 540
},
{
"candidate_id": "demo-house-07-2",
"votes": 420
},
{
"candidate_id": "demo-house-07-3",
"votes": 25
},
{
"candidate_id": "demo-house-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-08",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-08-1",
"votes": 510
},
{
"candidate_id": "demo-house-08-2",
"votes": 490
},
{
"candidate_id": "demo-house-08-3",
"votes": 0
},
{
"candidate_id": "demo-house-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-09",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-09-1",
"votes": 800
},
{
"candidate_id": "demo-house-09-2",
"votes": 150
},
{
"candidate_id": "demo-house-09-3",
"votes": 30
},
{
"candidate_id": "demo-house-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-10",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-10-1",
"votes": 600
},
{
"candidate_id": "demo-house-10-2",
"votes": 300
},
{
"candidate_id": "demo-house-10-3",
"votes": 70
},
{
"candidate_id": "demo-house-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-11",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-11-1",
"votes": 540
},
{
"candidate_id": "demo-house-11-2",
"votes": 420
},
{
"candidate_id": "demo-house-11-3",
"votes": 25
},
{
"candidate_id": "demo-house-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-12",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-12-1",
"votes": 510
},
{
"candidate_id": "demo-house-12-2",
"votes": 490
},
{
"candidate_id": "demo-house-12-3",
"votes": 0
},
{
"candidate_id": "demo-house-12-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-01",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-01-1",
"votes": 800
},
{
"candidate_id": "demo-senate-01-2",
"votes": 150
},
{
"candidate_id": "demo-senate-01-3",
"votes": 30
},
{
"candidate_id": "demo-senate-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-02",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-02-1",
"votes": 600
},
{
"candidate_id": "demo-senate-02-2",
"votes": 300
},
{
"candidate_id": "demo-senate-02-3",
"votes": 70
},
{
"candidate_id": "demo-senate-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-03",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-03-1",
"votes": 540
},
{
"candidate_id": "demo-senate-03-2",
"votes": 420
},
{
"candidate_id": "demo-senate-03-3",
"votes": 25
},
{
"candidate_id": "demo-senate-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-04",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-04-1",
"votes": 510
},
{
"candidate_id": "demo-senate-04-2",
"votes": 490
},
{
"candidate_id": "demo-senate-04-3",
"votes": 0
},
{
"candidate_id": "demo-senate-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-05",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-05-1",
"votes": 800
},
{
"candidate_id": "demo-senate-05-2",
"votes": 150
},
{
"candidate_id": "demo-senate-05-3",
"votes": 30
},
{
"candidate_id": "demo-senate-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-06",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-06-1",
"votes": 600
},
{
"candidate_id": "demo-senate-06-2",
"votes": 300
},
{
"candidate_id": "demo-senate-06-3",
"votes": 70
},
{
"candidate_id": "demo-senate-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-07",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-07-1",
"votes": 540
},
{
"candidate_id": "demo-senate-07-2",
"votes": 420
},
{
"candidate_id": "demo-senate-07-3",
"votes": 25
},
{
"candidate_id": "demo-senate-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-08",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-08-1",
"votes": 510
},
{
"candidate_id": "demo-senate-08-2",
"votes": 490
},
{
"candidate_id": "demo-senate-08-3",
"votes": 0
},
{
"candidate_id": "demo-senate-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-09",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-09-1",
"votes": 800
},
{
"candidate_id": "demo-senate-09-2",
"votes": 150
},
{
"candidate_id": "demo-senate-09-3",
"votes": 30
},
{
"candidate_id": "demo-senate-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-10",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-10-1",
"votes": 600
},
{
"candidate_id": "demo-senate-10-2",
"votes": 300
},
{
"candidate_id": "demo-senate-10-3",
"votes": 70
},
{
"candidate_id": "demo-senate-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-11",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-11-1",
"votes": 540
},
{
"candidate_id": "demo-senate-11-2",
"votes": 420
},
{
"candidate_id": "demo-senate-11-3",
"votes": 25
},
{
"candidate_id": "demo-senate-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-12",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-12-1",
"votes": 510
},
{
"candidate_id": "demo-senate-12-2",
"votes": 490
},
{
"candidate_id": "demo-senate-12-3",
"votes": 0
},
{
"candidate_id": "demo-senate-12-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-01",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-01-1",
"votes": 800
},
{
"candidate_id": "demo-governor-01-2",
"votes": 150
},
{
"candidate_id": "demo-governor-01-3",
"votes": 30
},
{
"candidate_id": "demo-governor-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-02",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-02-1",
"votes": 600
},
{
"candidate_id": "demo-governor-02-2",
"votes": 300
},
{
"candidate_id": "demo-governor-02-3",
"votes": 70
},
{
"candidate_id": "demo-governor-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-03",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-03-1",
"votes": 540
},
{
"candidate_id": "demo-governor-03-2",
"votes": 420
},
{
"candidate_id": "demo-governor-03-3",
"votes": 25
},
{
"candidate_id": "demo-governor-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-04",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-04-1",
"votes": 510
},
{
"candidate_id": "demo-governor-04-2",
"votes": 490
},
{
"candidate_id": "demo-governor-04-3",
"votes": 0
},
{
"candidate_id": "demo-governor-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-05",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-05-1",
"votes": 800
},
{
"candidate_id": "demo-governor-05-2",
"votes": 150
},
{
"candidate_id": "demo-governor-05-3",
"votes": 30
},
{
"candidate_id": "demo-governor-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-06",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-06-1",
"votes": 600
},
{
"candidate_id": "demo-governor-06-2",
"votes": 300
},
{
"candidate_id": "demo-governor-06-3",
"votes": 70
},
{
"candidate_id": "demo-governor-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-07",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-07-1",
"votes": 540
},
{
"candidate_id": "demo-governor-07-2",
"votes": 420
},
{
"candidate_id": "demo-governor-07-3",
"votes": 25
},
{
"candidate_id": "demo-governor-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-08",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-08-1",
"votes": 510
},
{
"candidate_id": "demo-governor-08-2",
"votes": 490
},
{
"candidate_id": "demo-governor-08-3",
"votes": 0
},
{
"candidate_id": "demo-governor-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-09",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-09-1",
"votes": 800
},
{
"candidate_id": "demo-governor-09-2",
"votes": 150
},
{
"candidate_id": "demo-governor-09-3",
"votes": 30
},
{
"candidate_id": "demo-governor-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-10",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-10-1",
"votes": 600
},
{
"candidate_id": "demo-governor-10-2",
"votes": 300
},
{
"candidate_id": "demo-governor-10-3",
"votes": 70
},
{
"candidate_id": "demo-governor-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-11",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-11-1",
"votes": 540
},
{
"candidate_id": "demo-governor-11-2",
"votes": 420
},
{
"candidate_id": "demo-governor-11-3",
"votes": 25
},
{
"candidate_id": "demo-governor-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-12",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-12-1",
"votes": 510
},
{
"candidate_id": "demo-governor-12-2",
"votes": 490
},
{
"candidate_id": "demo-governor-12-3",
"votes": 0
},
{
"candidate_id": "demo-governor-12-4",
"votes": 0
}
]
}
]
}
+916
View File
@@ -0,0 +1,916 @@
{
"schema_version": 1,
"id": "demo-results-2",
"kind": "results",
"mode": "demo",
"label": "Synthetic replay 3: Lead changes",
"as_of": "2026-11-04T02:00:00Z",
"source": {
"name": "Fictional scoring fixtures",
"url": "fixture:product-fpol/demo",
"retrieved_at": "2026-10-01T00:00:00Z"
},
"catalog_id": "demo-2026",
"entries": [
{
"race_id": "demo-house-01",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-01-1",
"votes": 800
},
{
"candidate_id": "demo-house-01-2",
"votes": 150
},
{
"candidate_id": "demo-house-01-3",
"votes": 30
},
{
"candidate_id": "demo-house-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-02",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-02-1",
"votes": 600
},
{
"candidate_id": "demo-house-02-2",
"votes": 300
},
{
"candidate_id": "demo-house-02-3",
"votes": 70
},
{
"candidate_id": "demo-house-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-03",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-03-1",
"votes": 540
},
{
"candidate_id": "demo-house-03-2",
"votes": 420
},
{
"candidate_id": "demo-house-03-3",
"votes": 25
},
{
"candidate_id": "demo-house-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-04",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-04-1",
"votes": 490
},
{
"candidate_id": "demo-house-04-2",
"votes": 510
},
{
"candidate_id": "demo-house-04-3",
"votes": 0
},
{
"candidate_id": "demo-house-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-05",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-05-1",
"votes": 800
},
{
"candidate_id": "demo-house-05-2",
"votes": 150
},
{
"candidate_id": "demo-house-05-3",
"votes": 30
},
{
"candidate_id": "demo-house-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-06",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-06-1",
"votes": 600
},
{
"candidate_id": "demo-house-06-2",
"votes": 300
},
{
"candidate_id": "demo-house-06-3",
"votes": 70
},
{
"candidate_id": "demo-house-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-07",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-07-1",
"votes": 540
},
{
"candidate_id": "demo-house-07-2",
"votes": 420
},
{
"candidate_id": "demo-house-07-3",
"votes": 25
},
{
"candidate_id": "demo-house-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-08",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-08-1",
"votes": 490
},
{
"candidate_id": "demo-house-08-2",
"votes": 510
},
{
"candidate_id": "demo-house-08-3",
"votes": 0
},
{
"candidate_id": "demo-house-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-09",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-09-1",
"votes": 800
},
{
"candidate_id": "demo-house-09-2",
"votes": 150
},
{
"candidate_id": "demo-house-09-3",
"votes": 30
},
{
"candidate_id": "demo-house-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-10",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-10-1",
"votes": 600
},
{
"candidate_id": "demo-house-10-2",
"votes": 300
},
{
"candidate_id": "demo-house-10-3",
"votes": 70
},
{
"candidate_id": "demo-house-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-11",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-11-1",
"votes": 540
},
{
"candidate_id": "demo-house-11-2",
"votes": 420
},
{
"candidate_id": "demo-house-11-3",
"votes": 25
},
{
"candidate_id": "demo-house-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-12",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-house-12-1",
"votes": 490
},
{
"candidate_id": "demo-house-12-2",
"votes": 510
},
{
"candidate_id": "demo-house-12-3",
"votes": 0
},
{
"candidate_id": "demo-house-12-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-01",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-01-1",
"votes": 800
},
{
"candidate_id": "demo-senate-01-2",
"votes": 150
},
{
"candidate_id": "demo-senate-01-3",
"votes": 30
},
{
"candidate_id": "demo-senate-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-02",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-02-1",
"votes": 600
},
{
"candidate_id": "demo-senate-02-2",
"votes": 300
},
{
"candidate_id": "demo-senate-02-3",
"votes": 70
},
{
"candidate_id": "demo-senate-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-03",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-03-1",
"votes": 540
},
{
"candidate_id": "demo-senate-03-2",
"votes": 420
},
{
"candidate_id": "demo-senate-03-3",
"votes": 25
},
{
"candidate_id": "demo-senate-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-04",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-04-1",
"votes": 490
},
{
"candidate_id": "demo-senate-04-2",
"votes": 510
},
{
"candidate_id": "demo-senate-04-3",
"votes": 0
},
{
"candidate_id": "demo-senate-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-05",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-05-1",
"votes": 800
},
{
"candidate_id": "demo-senate-05-2",
"votes": 150
},
{
"candidate_id": "demo-senate-05-3",
"votes": 30
},
{
"candidate_id": "demo-senate-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-06",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-06-1",
"votes": 600
},
{
"candidate_id": "demo-senate-06-2",
"votes": 300
},
{
"candidate_id": "demo-senate-06-3",
"votes": 70
},
{
"candidate_id": "demo-senate-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-07",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-07-1",
"votes": 540
},
{
"candidate_id": "demo-senate-07-2",
"votes": 420
},
{
"candidate_id": "demo-senate-07-3",
"votes": 25
},
{
"candidate_id": "demo-senate-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-08",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-08-1",
"votes": 490
},
{
"candidate_id": "demo-senate-08-2",
"votes": 510
},
{
"candidate_id": "demo-senate-08-3",
"votes": 0
},
{
"candidate_id": "demo-senate-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-09",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-09-1",
"votes": 800
},
{
"candidate_id": "demo-senate-09-2",
"votes": 150
},
{
"candidate_id": "demo-senate-09-3",
"votes": 30
},
{
"candidate_id": "demo-senate-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-10",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-10-1",
"votes": 600
},
{
"candidate_id": "demo-senate-10-2",
"votes": 300
},
{
"candidate_id": "demo-senate-10-3",
"votes": 70
},
{
"candidate_id": "demo-senate-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-11",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-11-1",
"votes": 540
},
{
"candidate_id": "demo-senate-11-2",
"votes": 420
},
{
"candidate_id": "demo-senate-11-3",
"votes": 25
},
{
"candidate_id": "demo-senate-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-12",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-senate-12-1",
"votes": 490
},
{
"candidate_id": "demo-senate-12-2",
"votes": 510
},
{
"candidate_id": "demo-senate-12-3",
"votes": 0
},
{
"candidate_id": "demo-senate-12-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-01",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-01-1",
"votes": 800
},
{
"candidate_id": "demo-governor-01-2",
"votes": 150
},
{
"candidate_id": "demo-governor-01-3",
"votes": 30
},
{
"candidate_id": "demo-governor-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-02",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-02-1",
"votes": 600
},
{
"candidate_id": "demo-governor-02-2",
"votes": 300
},
{
"candidate_id": "demo-governor-02-3",
"votes": 70
},
{
"candidate_id": "demo-governor-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-03",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-03-1",
"votes": 540
},
{
"candidate_id": "demo-governor-03-2",
"votes": 420
},
{
"candidate_id": "demo-governor-03-3",
"votes": 25
},
{
"candidate_id": "demo-governor-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-04",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-04-1",
"votes": 490
},
{
"candidate_id": "demo-governor-04-2",
"votes": 510
},
{
"candidate_id": "demo-governor-04-3",
"votes": 0
},
{
"candidate_id": "demo-governor-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-05",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-05-1",
"votes": 800
},
{
"candidate_id": "demo-governor-05-2",
"votes": 150
},
{
"candidate_id": "demo-governor-05-3",
"votes": 30
},
{
"candidate_id": "demo-governor-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-06",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-06-1",
"votes": 600
},
{
"candidate_id": "demo-governor-06-2",
"votes": 300
},
{
"candidate_id": "demo-governor-06-3",
"votes": 70
},
{
"candidate_id": "demo-governor-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-07",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-07-1",
"votes": 540
},
{
"candidate_id": "demo-governor-07-2",
"votes": 420
},
{
"candidate_id": "demo-governor-07-3",
"votes": 25
},
{
"candidate_id": "demo-governor-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-08",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-08-1",
"votes": 490
},
{
"candidate_id": "demo-governor-08-2",
"votes": 510
},
{
"candidate_id": "demo-governor-08-3",
"votes": 0
},
{
"candidate_id": "demo-governor-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-09",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-09-1",
"votes": 800
},
{
"candidate_id": "demo-governor-09-2",
"votes": 150
},
{
"candidate_id": "demo-governor-09-3",
"votes": 30
},
{
"candidate_id": "demo-governor-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-10",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-10-1",
"votes": 600
},
{
"candidate_id": "demo-governor-10-2",
"votes": 300
},
{
"candidate_id": "demo-governor-10-3",
"votes": 70
},
{
"candidate_id": "demo-governor-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-11",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-11-1",
"votes": 540
},
{
"candidate_id": "demo-governor-11-2",
"votes": 420
},
{
"candidate_id": "demo-governor-11-3",
"votes": 25
},
{
"candidate_id": "demo-governor-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-12",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{
"candidate_id": "demo-governor-12-1",
"votes": 490
},
{
"candidate_id": "demo-governor-12-2",
"votes": 510
},
{
"candidate_id": "demo-governor-12-3",
"votes": 0
},
{
"candidate_id": "demo-governor-12-4",
"votes": 0
}
]
}
]
}
+916
View File
@@ -0,0 +1,916 @@
{
"schema_version": 1,
"id": "demo-results-3",
"kind": "results",
"mode": "demo",
"label": "Synthetic replay 4: Races called",
"as_of": "2026-11-04T03:00:00Z",
"source": {
"name": "Fictional scoring fixtures",
"url": "fixture:product-fpol/demo",
"retrieved_at": "2026-10-01T00:00:00Z"
},
"catalog_id": "demo-2026",
"entries": [
{
"race_id": "demo-house-01",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-01-1",
"votes": [
{
"candidate_id": "demo-house-01-1",
"votes": 800
},
{
"candidate_id": "demo-house-01-2",
"votes": 150
},
{
"candidate_id": "demo-house-01-3",
"votes": 30
},
{
"candidate_id": "demo-house-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-02",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-02-1",
"votes": [
{
"candidate_id": "demo-house-02-1",
"votes": 600
},
{
"candidate_id": "demo-house-02-2",
"votes": 300
},
{
"candidate_id": "demo-house-02-3",
"votes": 70
},
{
"candidate_id": "demo-house-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-03",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-03-1",
"votes": [
{
"candidate_id": "demo-house-03-1",
"votes": 540
},
{
"candidate_id": "demo-house-03-2",
"votes": 420
},
{
"candidate_id": "demo-house-03-3",
"votes": 25
},
{
"candidate_id": "demo-house-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-04",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-04-1",
"votes": [
{
"candidate_id": "demo-house-04-1",
"votes": 510
},
{
"candidate_id": "demo-house-04-2",
"votes": 490
},
{
"candidate_id": "demo-house-04-3",
"votes": 0
},
{
"candidate_id": "demo-house-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-05",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-05-1",
"votes": [
{
"candidate_id": "demo-house-05-1",
"votes": 800
},
{
"candidate_id": "demo-house-05-2",
"votes": 150
},
{
"candidate_id": "demo-house-05-3",
"votes": 30
},
{
"candidate_id": "demo-house-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-06",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-06-1",
"votes": [
{
"candidate_id": "demo-house-06-1",
"votes": 600
},
{
"candidate_id": "demo-house-06-2",
"votes": 300
},
{
"candidate_id": "demo-house-06-3",
"votes": 70
},
{
"candidate_id": "demo-house-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-07",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-07-1",
"votes": [
{
"candidate_id": "demo-house-07-1",
"votes": 540
},
{
"candidate_id": "demo-house-07-2",
"votes": 420
},
{
"candidate_id": "demo-house-07-3",
"votes": 25
},
{
"candidate_id": "demo-house-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-08",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-08-1",
"votes": [
{
"candidate_id": "demo-house-08-1",
"votes": 510
},
{
"candidate_id": "demo-house-08-2",
"votes": 490
},
{
"candidate_id": "demo-house-08-3",
"votes": 0
},
{
"candidate_id": "demo-house-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-09",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-09-1",
"votes": [
{
"candidate_id": "demo-house-09-1",
"votes": 800
},
{
"candidate_id": "demo-house-09-2",
"votes": 150
},
{
"candidate_id": "demo-house-09-3",
"votes": 30
},
{
"candidate_id": "demo-house-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-10",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-10-1",
"votes": [
{
"candidate_id": "demo-house-10-1",
"votes": 600
},
{
"candidate_id": "demo-house-10-2",
"votes": 300
},
{
"candidate_id": "demo-house-10-3",
"votes": 70
},
{
"candidate_id": "demo-house-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-11",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-11-1",
"votes": [
{
"candidate_id": "demo-house-11-1",
"votes": 540
},
{
"candidate_id": "demo-house-11-2",
"votes": 420
},
{
"candidate_id": "demo-house-11-3",
"votes": 25
},
{
"candidate_id": "demo-house-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-12",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-12-1",
"votes": [
{
"candidate_id": "demo-house-12-1",
"votes": 510
},
{
"candidate_id": "demo-house-12-2",
"votes": 490
},
{
"candidate_id": "demo-house-12-3",
"votes": 0
},
{
"candidate_id": "demo-house-12-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-01",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-01-1",
"votes": [
{
"candidate_id": "demo-senate-01-1",
"votes": 800
},
{
"candidate_id": "demo-senate-01-2",
"votes": 150
},
{
"candidate_id": "demo-senate-01-3",
"votes": 30
},
{
"candidate_id": "demo-senate-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-02",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-02-1",
"votes": [
{
"candidate_id": "demo-senate-02-1",
"votes": 600
},
{
"candidate_id": "demo-senate-02-2",
"votes": 300
},
{
"candidate_id": "demo-senate-02-3",
"votes": 70
},
{
"candidate_id": "demo-senate-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-03",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-03-1",
"votes": [
{
"candidate_id": "demo-senate-03-1",
"votes": 540
},
{
"candidate_id": "demo-senate-03-2",
"votes": 420
},
{
"candidate_id": "demo-senate-03-3",
"votes": 25
},
{
"candidate_id": "demo-senate-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-04",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-04-1",
"votes": [
{
"candidate_id": "demo-senate-04-1",
"votes": 510
},
{
"candidate_id": "demo-senate-04-2",
"votes": 490
},
{
"candidate_id": "demo-senate-04-3",
"votes": 0
},
{
"candidate_id": "demo-senate-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-05",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-05-1",
"votes": [
{
"candidate_id": "demo-senate-05-1",
"votes": 800
},
{
"candidate_id": "demo-senate-05-2",
"votes": 150
},
{
"candidate_id": "demo-senate-05-3",
"votes": 30
},
{
"candidate_id": "demo-senate-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-06",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-06-1",
"votes": [
{
"candidate_id": "demo-senate-06-1",
"votes": 600
},
{
"candidate_id": "demo-senate-06-2",
"votes": 300
},
{
"candidate_id": "demo-senate-06-3",
"votes": 70
},
{
"candidate_id": "demo-senate-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-07",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-07-1",
"votes": [
{
"candidate_id": "demo-senate-07-1",
"votes": 540
},
{
"candidate_id": "demo-senate-07-2",
"votes": 420
},
{
"candidate_id": "demo-senate-07-3",
"votes": 25
},
{
"candidate_id": "demo-senate-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-08",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-08-1",
"votes": [
{
"candidate_id": "demo-senate-08-1",
"votes": 510
},
{
"candidate_id": "demo-senate-08-2",
"votes": 490
},
{
"candidate_id": "demo-senate-08-3",
"votes": 0
},
{
"candidate_id": "demo-senate-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-09",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-09-1",
"votes": [
{
"candidate_id": "demo-senate-09-1",
"votes": 800
},
{
"candidate_id": "demo-senate-09-2",
"votes": 150
},
{
"candidate_id": "demo-senate-09-3",
"votes": 30
},
{
"candidate_id": "demo-senate-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-10",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-10-1",
"votes": [
{
"candidate_id": "demo-senate-10-1",
"votes": 600
},
{
"candidate_id": "demo-senate-10-2",
"votes": 300
},
{
"candidate_id": "demo-senate-10-3",
"votes": 70
},
{
"candidate_id": "demo-senate-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-11",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-11-1",
"votes": [
{
"candidate_id": "demo-senate-11-1",
"votes": 540
},
{
"candidate_id": "demo-senate-11-2",
"votes": 420
},
{
"candidate_id": "demo-senate-11-3",
"votes": 25
},
{
"candidate_id": "demo-senate-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-12",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-12-1",
"votes": [
{
"candidate_id": "demo-senate-12-1",
"votes": 510
},
{
"candidate_id": "demo-senate-12-2",
"votes": 490
},
{
"candidate_id": "demo-senate-12-3",
"votes": 0
},
{
"candidate_id": "demo-senate-12-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-01",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-01-1",
"votes": [
{
"candidate_id": "demo-governor-01-1",
"votes": 800
},
{
"candidate_id": "demo-governor-01-2",
"votes": 150
},
{
"candidate_id": "demo-governor-01-3",
"votes": 30
},
{
"candidate_id": "demo-governor-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-02",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-02-1",
"votes": [
{
"candidate_id": "demo-governor-02-1",
"votes": 600
},
{
"candidate_id": "demo-governor-02-2",
"votes": 300
},
{
"candidate_id": "demo-governor-02-3",
"votes": 70
},
{
"candidate_id": "demo-governor-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-03",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-03-1",
"votes": [
{
"candidate_id": "demo-governor-03-1",
"votes": 540
},
{
"candidate_id": "demo-governor-03-2",
"votes": 420
},
{
"candidate_id": "demo-governor-03-3",
"votes": 25
},
{
"candidate_id": "demo-governor-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-04",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-04-1",
"votes": [
{
"candidate_id": "demo-governor-04-1",
"votes": 510
},
{
"candidate_id": "demo-governor-04-2",
"votes": 490
},
{
"candidate_id": "demo-governor-04-3",
"votes": 0
},
{
"candidate_id": "demo-governor-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-05",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-05-1",
"votes": [
{
"candidate_id": "demo-governor-05-1",
"votes": 800
},
{
"candidate_id": "demo-governor-05-2",
"votes": 150
},
{
"candidate_id": "demo-governor-05-3",
"votes": 30
},
{
"candidate_id": "demo-governor-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-06",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-06-1",
"votes": [
{
"candidate_id": "demo-governor-06-1",
"votes": 600
},
{
"candidate_id": "demo-governor-06-2",
"votes": 300
},
{
"candidate_id": "demo-governor-06-3",
"votes": 70
},
{
"candidate_id": "demo-governor-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-07",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-07-1",
"votes": [
{
"candidate_id": "demo-governor-07-1",
"votes": 540
},
{
"candidate_id": "demo-governor-07-2",
"votes": 420
},
{
"candidate_id": "demo-governor-07-3",
"votes": 25
},
{
"candidate_id": "demo-governor-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-08",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-08-1",
"votes": [
{
"candidate_id": "demo-governor-08-1",
"votes": 510
},
{
"candidate_id": "demo-governor-08-2",
"votes": 490
},
{
"candidate_id": "demo-governor-08-3",
"votes": 0
},
{
"candidate_id": "demo-governor-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-09",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-09-1",
"votes": [
{
"candidate_id": "demo-governor-09-1",
"votes": 800
},
{
"candidate_id": "demo-governor-09-2",
"votes": 150
},
{
"candidate_id": "demo-governor-09-3",
"votes": 30
},
{
"candidate_id": "demo-governor-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-10",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-10-1",
"votes": [
{
"candidate_id": "demo-governor-10-1",
"votes": 600
},
{
"candidate_id": "demo-governor-10-2",
"votes": 300
},
{
"candidate_id": "demo-governor-10-3",
"votes": 70
},
{
"candidate_id": "demo-governor-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-11",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-11-1",
"votes": [
{
"candidate_id": "demo-governor-11-1",
"votes": 540
},
{
"candidate_id": "demo-governor-11-2",
"votes": 420
},
{
"candidate_id": "demo-governor-11-3",
"votes": 25
},
{
"candidate_id": "demo-governor-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-12",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-12-1",
"votes": [
{
"candidate_id": "demo-governor-12-1",
"votes": 510
},
{
"candidate_id": "demo-governor-12-2",
"votes": 490
},
{
"candidate_id": "demo-governor-12-3",
"votes": 0
},
{
"candidate_id": "demo-governor-12-4",
"votes": 0
}
]
}
]
}
+916
View File
@@ -0,0 +1,916 @@
{
"schema_version": 1,
"id": "demo-results-4",
"kind": "results",
"mode": "demo",
"label": "Synthetic replay 5: Corrected totals and call",
"as_of": "2026-11-04T04:00:00Z",
"source": {
"name": "Fictional scoring fixtures",
"url": "fixture:product-fpol/demo",
"retrieved_at": "2026-10-01T00:00:00Z"
},
"catalog_id": "demo-2026",
"entries": [
{
"race_id": "demo-house-01",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-01-1",
"votes": [
{
"candidate_id": "demo-house-01-1",
"votes": 800
},
{
"candidate_id": "demo-house-01-2",
"votes": 150
},
{
"candidate_id": "demo-house-01-3",
"votes": 30
},
{
"candidate_id": "demo-house-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-02",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-02-1",
"votes": [
{
"candidate_id": "demo-house-02-1",
"votes": 600
},
{
"candidate_id": "demo-house-02-2",
"votes": 300
},
{
"candidate_id": "demo-house-02-3",
"votes": 70
},
{
"candidate_id": "demo-house-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-03",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-03-1",
"votes": [
{
"candidate_id": "demo-house-03-1",
"votes": 540
},
{
"candidate_id": "demo-house-03-2",
"votes": 420
},
{
"candidate_id": "demo-house-03-3",
"votes": 25
},
{
"candidate_id": "demo-house-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-04",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-04-2",
"votes": [
{
"candidate_id": "demo-house-04-1",
"votes": 490
},
{
"candidate_id": "demo-house-04-2",
"votes": 510
},
{
"candidate_id": "demo-house-04-3",
"votes": 0
},
{
"candidate_id": "demo-house-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-05",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-05-1",
"votes": [
{
"candidate_id": "demo-house-05-1",
"votes": 800
},
{
"candidate_id": "demo-house-05-2",
"votes": 150
},
{
"candidate_id": "demo-house-05-3",
"votes": 30
},
{
"candidate_id": "demo-house-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-06",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-06-1",
"votes": [
{
"candidate_id": "demo-house-06-1",
"votes": 600
},
{
"candidate_id": "demo-house-06-2",
"votes": 300
},
{
"candidate_id": "demo-house-06-3",
"votes": 70
},
{
"candidate_id": "demo-house-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-07",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-07-1",
"votes": [
{
"candidate_id": "demo-house-07-1",
"votes": 540
},
{
"candidate_id": "demo-house-07-2",
"votes": 420
},
{
"candidate_id": "demo-house-07-3",
"votes": 25
},
{
"candidate_id": "demo-house-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-08",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-08-2",
"votes": [
{
"candidate_id": "demo-house-08-1",
"votes": 490
},
{
"candidate_id": "demo-house-08-2",
"votes": 510
},
{
"candidate_id": "demo-house-08-3",
"votes": 0
},
{
"candidate_id": "demo-house-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-house-09",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-09-1",
"votes": [
{
"candidate_id": "demo-house-09-1",
"votes": 800
},
{
"candidate_id": "demo-house-09-2",
"votes": 150
},
{
"candidate_id": "demo-house-09-3",
"votes": 30
},
{
"candidate_id": "demo-house-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-house-10",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-10-1",
"votes": [
{
"candidate_id": "demo-house-10-1",
"votes": 600
},
{
"candidate_id": "demo-house-10-2",
"votes": 300
},
{
"candidate_id": "demo-house-10-3",
"votes": 70
},
{
"candidate_id": "demo-house-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-house-11",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-11-1",
"votes": [
{
"candidate_id": "demo-house-11-1",
"votes": 540
},
{
"candidate_id": "demo-house-11-2",
"votes": 420
},
{
"candidate_id": "demo-house-11-3",
"votes": 25
},
{
"candidate_id": "demo-house-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-house-12",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-house-12-2",
"votes": [
{
"candidate_id": "demo-house-12-1",
"votes": 490
},
{
"candidate_id": "demo-house-12-2",
"votes": 510
},
{
"candidate_id": "demo-house-12-3",
"votes": 0
},
{
"candidate_id": "demo-house-12-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-01",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-01-1",
"votes": [
{
"candidate_id": "demo-senate-01-1",
"votes": 800
},
{
"candidate_id": "demo-senate-01-2",
"votes": 150
},
{
"candidate_id": "demo-senate-01-3",
"votes": 30
},
{
"candidate_id": "demo-senate-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-02",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-02-1",
"votes": [
{
"candidate_id": "demo-senate-02-1",
"votes": 600
},
{
"candidate_id": "demo-senate-02-2",
"votes": 300
},
{
"candidate_id": "demo-senate-02-3",
"votes": 70
},
{
"candidate_id": "demo-senate-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-03",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-03-1",
"votes": [
{
"candidate_id": "demo-senate-03-1",
"votes": 540
},
{
"candidate_id": "demo-senate-03-2",
"votes": 420
},
{
"candidate_id": "demo-senate-03-3",
"votes": 25
},
{
"candidate_id": "demo-senate-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-04",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-04-2",
"votes": [
{
"candidate_id": "demo-senate-04-1",
"votes": 490
},
{
"candidate_id": "demo-senate-04-2",
"votes": 510
},
{
"candidate_id": "demo-senate-04-3",
"votes": 0
},
{
"candidate_id": "demo-senate-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-05",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-05-1",
"votes": [
{
"candidate_id": "demo-senate-05-1",
"votes": 800
},
{
"candidate_id": "demo-senate-05-2",
"votes": 150
},
{
"candidate_id": "demo-senate-05-3",
"votes": 30
},
{
"candidate_id": "demo-senate-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-06",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-06-1",
"votes": [
{
"candidate_id": "demo-senate-06-1",
"votes": 600
},
{
"candidate_id": "demo-senate-06-2",
"votes": 300
},
{
"candidate_id": "demo-senate-06-3",
"votes": 70
},
{
"candidate_id": "demo-senate-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-07",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-07-1",
"votes": [
{
"candidate_id": "demo-senate-07-1",
"votes": 540
},
{
"candidate_id": "demo-senate-07-2",
"votes": 420
},
{
"candidate_id": "demo-senate-07-3",
"votes": 25
},
{
"candidate_id": "demo-senate-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-08",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-08-2",
"votes": [
{
"candidate_id": "demo-senate-08-1",
"votes": 490
},
{
"candidate_id": "demo-senate-08-2",
"votes": 510
},
{
"candidate_id": "demo-senate-08-3",
"votes": 0
},
{
"candidate_id": "demo-senate-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-senate-09",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-09-1",
"votes": [
{
"candidate_id": "demo-senate-09-1",
"votes": 800
},
{
"candidate_id": "demo-senate-09-2",
"votes": 150
},
{
"candidate_id": "demo-senate-09-3",
"votes": 30
},
{
"candidate_id": "demo-senate-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-senate-10",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-10-1",
"votes": [
{
"candidate_id": "demo-senate-10-1",
"votes": 600
},
{
"candidate_id": "demo-senate-10-2",
"votes": 300
},
{
"candidate_id": "demo-senate-10-3",
"votes": 70
},
{
"candidate_id": "demo-senate-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-senate-11",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-11-1",
"votes": [
{
"candidate_id": "demo-senate-11-1",
"votes": 540
},
{
"candidate_id": "demo-senate-11-2",
"votes": 420
},
{
"candidate_id": "demo-senate-11-3",
"votes": 25
},
{
"candidate_id": "demo-senate-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-senate-12",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-senate-12-2",
"votes": [
{
"candidate_id": "demo-senate-12-1",
"votes": 490
},
{
"candidate_id": "demo-senate-12-2",
"votes": 510
},
{
"candidate_id": "demo-senate-12-3",
"votes": 0
},
{
"candidate_id": "demo-senate-12-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-01",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-01-1",
"votes": [
{
"candidate_id": "demo-governor-01-1",
"votes": 800
},
{
"candidate_id": "demo-governor-01-2",
"votes": 150
},
{
"candidate_id": "demo-governor-01-3",
"votes": 30
},
{
"candidate_id": "demo-governor-01-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-02",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-02-1",
"votes": [
{
"candidate_id": "demo-governor-02-1",
"votes": 600
},
{
"candidate_id": "demo-governor-02-2",
"votes": 300
},
{
"candidate_id": "demo-governor-02-3",
"votes": 70
},
{
"candidate_id": "demo-governor-02-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-03",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-03-1",
"votes": [
{
"candidate_id": "demo-governor-03-1",
"votes": 540
},
{
"candidate_id": "demo-governor-03-2",
"votes": 420
},
{
"candidate_id": "demo-governor-03-3",
"votes": 25
},
{
"candidate_id": "demo-governor-03-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-04",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-04-2",
"votes": [
{
"candidate_id": "demo-governor-04-1",
"votes": 490
},
{
"candidate_id": "demo-governor-04-2",
"votes": 510
},
{
"candidate_id": "demo-governor-04-3",
"votes": 0
},
{
"candidate_id": "demo-governor-04-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-05",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-05-1",
"votes": [
{
"candidate_id": "demo-governor-05-1",
"votes": 800
},
{
"candidate_id": "demo-governor-05-2",
"votes": 150
},
{
"candidate_id": "demo-governor-05-3",
"votes": 30
},
{
"candidate_id": "demo-governor-05-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-06",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-06-1",
"votes": [
{
"candidate_id": "demo-governor-06-1",
"votes": 600
},
{
"candidate_id": "demo-governor-06-2",
"votes": 300
},
{
"candidate_id": "demo-governor-06-3",
"votes": 70
},
{
"candidate_id": "demo-governor-06-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-07",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-07-1",
"votes": [
{
"candidate_id": "demo-governor-07-1",
"votes": 540
},
{
"candidate_id": "demo-governor-07-2",
"votes": 420
},
{
"candidate_id": "demo-governor-07-3",
"votes": 25
},
{
"candidate_id": "demo-governor-07-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-08",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-08-2",
"votes": [
{
"candidate_id": "demo-governor-08-1",
"votes": 490
},
{
"candidate_id": "demo-governor-08-2",
"votes": 510
},
{
"candidate_id": "demo-governor-08-3",
"votes": 0
},
{
"candidate_id": "demo-governor-08-4",
"votes": 0
}
]
},
{
"race_id": "demo-governor-09",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-09-1",
"votes": [
{
"candidate_id": "demo-governor-09-1",
"votes": 800
},
{
"candidate_id": "demo-governor-09-2",
"votes": 150
},
{
"candidate_id": "demo-governor-09-3",
"votes": 30
},
{
"candidate_id": "demo-governor-09-4",
"votes": 20
}
]
},
{
"race_id": "demo-governor-10",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-10-1",
"votes": [
{
"candidate_id": "demo-governor-10-1",
"votes": 600
},
{
"candidate_id": "demo-governor-10-2",
"votes": 300
},
{
"candidate_id": "demo-governor-10-3",
"votes": 70
},
{
"candidate_id": "demo-governor-10-4",
"votes": 30
}
]
},
{
"race_id": "demo-governor-11",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-11-1",
"votes": [
{
"candidate_id": "demo-governor-11-1",
"votes": 540
},
{
"candidate_id": "demo-governor-11-2",
"votes": 420
},
{
"candidate_id": "demo-governor-11-3",
"votes": 25
},
{
"candidate_id": "demo-governor-11-4",
"votes": 15
}
]
},
{
"race_id": "demo-governor-12",
"round": "general",
"status": "called",
"total_votes": 1000,
"winner_id": "demo-governor-12-2",
"votes": [
{
"candidate_id": "demo-governor-12-1",
"votes": 490
},
{
"candidate_id": "demo-governor-12-2",
"votes": 510
},
{
"candidate_id": "demo-governor-12-3",
"votes": 0
},
{
"candidate_id": "demo-governor-12-4",
"votes": 0
}
]
}
]
}
File diff suppressed because it is too large Load Diff
+838
View File
@@ -0,0 +1,838 @@
{
"schema_version": 1,
"kind": "catalog",
"id": "nc-2026-10-04",
"mode": "real",
"label": "North Carolina official list · partial US catalog",
"as_of": "2026-10-04T00:00:00Z",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"complete": false,
"races": [
{
"id": "2026-nc-senate-general",
"state": "NC",
"office": "senate",
"round": "general",
"name": "North Carolina Senate"
},
{
"id": "2026-nc-house-01-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 01"
},
{
"id": "2026-nc-house-02-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 02"
},
{
"id": "2026-nc-house-03-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 03"
},
{
"id": "2026-nc-house-04-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 04"
},
{
"id": "2026-nc-house-05-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 05"
},
{
"id": "2026-nc-house-06-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 06"
},
{
"id": "2026-nc-house-07-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 07"
},
{
"id": "2026-nc-house-08-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 08"
},
{
"id": "2026-nc-house-09-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 09"
},
{
"id": "2026-nc-house-10-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 10"
},
{
"id": "2026-nc-house-11-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 11"
},
{
"id": "2026-nc-house-12-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 12"
},
{
"id": "2026-nc-house-13-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 13"
},
{
"id": "2026-nc-house-14-general",
"state": "NC",
"office": "house",
"round": "general",
"name": "North Carolina House 14"
}
],
"candidates": [
{
"id": "2026-nc-senate-general-michael-dublin",
"race_id": "2026-nc-senate-general",
"name": "Michael Dublin",
"party": "GRE",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"GRE"
],
"provider_ids": {}
},
{
"id": "2026-nc-senate-general-roy-cooper",
"race_id": "2026-nc-senate-general",
"name": "Roy Cooper",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-senate-general-shannon-w-bray",
"race_id": "2026-nc-senate-general",
"name": "Shannon W. Bray",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-senate-general-michael-whatley",
"race_id": "2026-nc-senate-general",
"name": "Michael Whatley",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-01-general-don-davis",
"race_id": "2026-nc-house-01-general",
"name": "Don Davis",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-01-general-laurie-buckhout",
"race_id": "2026-nc-house-01-general",
"name": "Laurie Buckhout",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-01-general-tom-bailey",
"race_id": "2026-nc-house-01-general",
"name": "Tom Bailey",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-02-general-eugene-f-douglass",
"race_id": "2026-nc-house-02-general",
"name": "Eugene F. Douglass",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-02-general-deborah-k-ross",
"race_id": "2026-nc-house-02-general",
"name": "Deborah K. Ross",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-02-general-matthew-laszacs",
"race_id": "2026-nc-house-02-general",
"name": "Matthew Laszacs",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-03-general-daniel-cavender",
"race_id": "2026-nc-house-03-general",
"name": "Daniel Cavender",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-03-general-raymond-smith",
"race_id": "2026-nc-house-03-general",
"name": "Raymond Smith",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-03-general-greg-murphy",
"race_id": "2026-nc-house-03-general",
"name": "Greg Murphy",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-04-general-valerie-p-foushee",
"race_id": "2026-nc-house-04-general",
"name": "Valerie P. Foushee",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-04-general-guy-meilleur",
"race_id": "2026-nc-house-04-general",
"name": "Guy Meilleur",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-04-general-mahesh-max-ganorkar",
"race_id": "2026-nc-house-04-general",
"name": "Mahesh (Max) Ganorkar",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-05-general-virginia-foxx",
"race_id": "2026-nc-house-05-general",
"name": "Virginia Foxx",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-05-general-robert-b-luffman",
"race_id": "2026-nc-house-05-general",
"name": "Robert B. Luffman",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-05-general-chuck-hubbard",
"race_id": "2026-nc-house-05-general",
"name": "Chuck Hubbard",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-06-general-addison-mcdowell",
"race_id": "2026-nc-house-06-general",
"name": "Addison McDowell",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-06-general-cyril-jefferson",
"race_id": "2026-nc-house-06-general",
"name": "Cyril Jefferson",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-07-general-maad-abu-ghazalah",
"race_id": "2026-nc-house-07-general",
"name": "Maad Abu-Ghazalah",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-07-general-david-rouzer",
"race_id": "2026-nc-house-07-general",
"name": "David Rouzer",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-07-general-kimberly-hardy",
"race_id": "2026-nc-house-07-general",
"name": "Kimberly Hardy",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-08-general-bo-whitehead",
"race_id": "2026-nc-house-08-general",
"name": "Bo Whitehead",
"party": "GRE",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"GRE"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-08-general-colby-watson",
"race_id": "2026-nc-house-08-general",
"name": "Colby Watson",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-08-general-mark-harris",
"race_id": "2026-nc-house-08-general",
"name": "Mark Harris",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-09-general-richard-n-ojeda-ii",
"race_id": "2026-nc-house-09-general",
"name": "Richard N. Ojeda II",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-09-general-richard-hudson",
"race_id": "2026-nc-house-09-general",
"name": "Richard Hudson",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-10-general-steven-feldman",
"race_id": "2026-nc-house-10-general",
"name": "Steven Feldman",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-10-general-ashley-bell",
"race_id": "2026-nc-house-10-general",
"name": "Ashley Bell",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-10-general-pat-harrigan",
"race_id": "2026-nc-house-10-general",
"name": "Pat Harrigan",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-11-general-jennifer-capps-balkcom",
"race_id": "2026-nc-house-11-general",
"name": "Jennifer Capps Balkcom",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-11-general-jamie-ager",
"race_id": "2026-nc-house-11-general",
"name": "Jamie Ager",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-11-general-travis-groo",
"race_id": "2026-nc-house-11-general",
"name": "Travis Groo",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-12-general-jack-codiga",
"race_id": "2026-nc-house-12-general",
"name": "Jack Codiga",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-12-general-alma-s-adams",
"race_id": "2026-nc-house-12-general",
"name": "Alma S. Adams",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-13-general-paul-barringer",
"race_id": "2026-nc-house-13-general",
"name": "Paul Barringer",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-13-general-steven-swinton",
"race_id": "2026-nc-house-13-general",
"name": "Steven Swinton",
"party": "LIB",
"affiliation": "other",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"LIB"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-13-general-brad-knott",
"race_id": "2026-nc-house-13-general",
"name": "Brad Knott",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-14-general-lakesha-womack",
"race_id": "2026-nc-house-14-general",
"name": "Lakesha Womack",
"party": "DEM",
"affiliation": "democrat",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"DEM"
],
"provider_ids": {}
},
{
"id": "2026-nc-house-14-general-tim-moore",
"race_id": "2026-nc-house-14-general",
"name": "Tim Moore",
"party": "REP",
"affiliation": "republican",
"source": {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z"
},
"qualified": true,
"ballot_lines": [
"REP"
],
"provider_ids": {}
}
],
"notes": "Reviewed from official PDF pages 1–3, report dated 2026-09-21. Retrieval has day precision; midnight is a date placeholder. Federal contests only; NC has no scheduled 2026 governor race. Refresh against official changes before freezing."
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
{
"id": "civicapi-status",
"kind": "civicapi-probe",
"catalog_id": "us-2026-10-04-partial",
"url": "https://civicapi.org/api/v2/status",
"races": []
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "nc-candidate-watch",
"kind": "candidate-watch",
"catalog_id": "us-2026-10-04-partial",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"races": []
}
+147
View File
@@ -0,0 +1,147 @@
{
"id": "openamerica-nc-house",
"kind": "openamerica",
"catalog_id": "us-2026-10-04-partial",
"url": "https://openamerica.io/elections/results.json?year=2026&office=House",
"office": "House",
"counts_until": "2026-11-20T00:00:00Z",
"review_note": "PROPOSED mappings from official ballot spellings, not a captured provider response. Probe, compare exact spellings/party codes and first-round semantics, then activate only after every configured race is observed.",
"races": [
{
"race_id": "2026-nc-house-01-general",
"role": "counts",
"provider_id": "NC-01",
"candidates": {
"Don Davis|D": "2026-nc-house-01-general-don-davis",
"Laurie Buckhout|R": "2026-nc-house-01-general-laurie-buckhout",
"Tom Bailey|L": "2026-nc-house-01-general-tom-bailey"
}
},
{
"race_id": "2026-nc-house-02-general",
"role": "counts",
"provider_id": "NC-02",
"candidates": {
"Eugene F. Douglass|R": "2026-nc-house-02-general-eugene-f-douglass",
"Deborah K. Ross|D": "2026-nc-house-02-general-deborah-k-ross",
"Matthew Laszacs|L": "2026-nc-house-02-general-matthew-laszacs"
}
},
{
"race_id": "2026-nc-house-03-general",
"role": "counts",
"provider_id": "NC-03",
"candidates": {
"Daniel Cavender|L": "2026-nc-house-03-general-daniel-cavender",
"Raymond Smith|D": "2026-nc-house-03-general-raymond-smith",
"Greg Murphy|R": "2026-nc-house-03-general-greg-murphy"
}
},
{
"race_id": "2026-nc-house-04-general",
"role": "counts",
"provider_id": "NC-04",
"candidates": {
"Valerie P. Foushee|D": "2026-nc-house-04-general-valerie-p-foushee",
"Guy Meilleur|L": "2026-nc-house-04-general-guy-meilleur",
"Mahesh (Max) Ganorkar|R": "2026-nc-house-04-general-mahesh-max-ganorkar"
}
},
{
"race_id": "2026-nc-house-05-general",
"role": "counts",
"provider_id": "NC-05",
"candidates": {
"Virginia Foxx|R": "2026-nc-house-05-general-virginia-foxx",
"Robert B. Luffman|L": "2026-nc-house-05-general-robert-b-luffman",
"Chuck Hubbard|D": "2026-nc-house-05-general-chuck-hubbard"
}
},
{
"race_id": "2026-nc-house-06-general",
"role": "counts",
"provider_id": "NC-06",
"candidates": {
"Addison McDowell|R": "2026-nc-house-06-general-addison-mcdowell",
"Cyril Jefferson|D": "2026-nc-house-06-general-cyril-jefferson"
}
},
{
"race_id": "2026-nc-house-07-general",
"role": "counts",
"provider_id": "NC-07",
"candidates": {
"Maad Abu-Ghazalah|L": "2026-nc-house-07-general-maad-abu-ghazalah",
"David Rouzer|R": "2026-nc-house-07-general-david-rouzer",
"Kimberly Hardy|D": "2026-nc-house-07-general-kimberly-hardy"
}
},
{
"race_id": "2026-nc-house-08-general",
"role": "counts",
"provider_id": "NC-08",
"candidates": {
"Bo Whitehead|G": "2026-nc-house-08-general-bo-whitehead",
"Colby Watson|D": "2026-nc-house-08-general-colby-watson",
"Mark Harris|R": "2026-nc-house-08-general-mark-harris"
}
},
{
"race_id": "2026-nc-house-09-general",
"role": "counts",
"provider_id": "NC-09",
"candidates": {
"Richard N. Ojeda II|D": "2026-nc-house-09-general-richard-n-ojeda-ii",
"Richard Hudson|R": "2026-nc-house-09-general-richard-hudson"
}
},
{
"race_id": "2026-nc-house-10-general",
"role": "counts",
"provider_id": "NC-10",
"candidates": {
"Steven Feldman|L": "2026-nc-house-10-general-steven-feldman",
"Ashley Bell|D": "2026-nc-house-10-general-ashley-bell",
"Pat Harrigan|R": "2026-nc-house-10-general-pat-harrigan"
}
},
{
"race_id": "2026-nc-house-11-general",
"role": "counts",
"provider_id": "NC-11",
"candidates": {
"Jennifer Capps Balkcom|R": "2026-nc-house-11-general-jennifer-capps-balkcom",
"Jamie Ager|D": "2026-nc-house-11-general-jamie-ager",
"Travis Groo|L": "2026-nc-house-11-general-travis-groo"
}
},
{
"race_id": "2026-nc-house-12-general",
"role": "counts",
"provider_id": "NC-12",
"candidates": {
"Jack Codiga|R": "2026-nc-house-12-general-jack-codiga",
"Alma S. Adams|D": "2026-nc-house-12-general-alma-s-adams"
}
},
{
"race_id": "2026-nc-house-13-general",
"role": "counts",
"provider_id": "NC-13",
"candidates": {
"Paul Barringer|D": "2026-nc-house-13-general-paul-barringer",
"Steven Swinton|L": "2026-nc-house-13-general-steven-swinton",
"Brad Knott|R": "2026-nc-house-13-general-brad-knott"
}
},
{
"race_id": "2026-nc-house-14-general",
"role": "counts",
"provider_id": "NC-14",
"candidates": {
"Lakesha Womack|D": "2026-nc-house-14-general-lakesha-womack",
"Tim Moore|R": "2026-nc-house-14-general-tim-moore"
}
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"id": "openamerica-nc-senate",
"kind": "openamerica",
"catalog_id": "us-2026-10-04-partial",
"url": "https://openamerica.io/elections/results.json?year=2026&office=Senate",
"office": "Senate",
"counts_until": "2026-11-20T00:00:00Z",
"review_note": "PROPOSED mappings from official ballot spellings, not a captured provider response. Probe, compare exact spellings/party codes and first-round semantics, then activate only after every configured race is observed.",
"races": [
{
"race_id": "2026-nc-senate-general",
"role": "counts",
"provider_id": "NC",
"candidates": {
"Michael Dublin|G": "2026-nc-senate-general-michael-dublin",
"Roy Cooper|D": "2026-nc-senate-general-roy-cooper",
"Shannon W. Bray|L": "2026-nc-senate-general-shannon-w-bray",
"Michael Whatley|R": "2026-nc-senate-general-michael-whatley"
}
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{$FPOL_DOMAIN} {
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy no-referrer
X-Frame-Options DENY
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
}
reverse_proxy app:8080
}
+33
View File
@@ -0,0 +1,33 @@
# Build context is the parent workspace containing product-fpol, modulus, noto and ligo.
FROM node:24-bookworm-slim AS frontend
WORKDIR /frontend
COPY product-fpol/frontend/package*.json ./
RUN npm ci
COPY product-fpol/frontend/index.html product-fpol/frontend/lab.html product-fpol/frontend/tsconfig.json product-fpol/frontend/vite.config.ts ./
COPY product-fpol/frontend/src ./src
RUN npm run build
FROM ubuntu:24.04 AS backend
ENV DEBIAN_FRONTEND=noninteractive CC=gcc-12 CXX=g++-12
RUN apt-get update && apt-get install -y --no-install-recommends gcc-12 g++-12 cmake ninja-build make pkg-config git ca-certificates python3 python3-venv perl && rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /opt/build-python && /opt/build-python/bin/pip install conan==2.26.2
ENV PATH="/opt/build-python/bin:${PATH}"
WORKDIR /workspace
COPY modulus ./modulus
COPY noto ./noto
COPY ligo ./ligo
COPY product-fpol ./product-fpol
RUN conan profile detect && python product-fpol/tools/build_workspace.py --build-type Release
FROM ubuntu:24.04 AS runtime
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libstdc++6 curl && rm -rf /var/lib/apt/lists/* && useradd --uid 10001 --create-home fpol && mkdir -p /data/backups && chown -R fpol:fpol /data && chmod 700 /data
COPY --from=backend /workspace/product-fpol/build/workspace_release/fpol_server /workspace/product-fpol/build/workspace_release/fpol_admin /workspace/product-fpol/build/workspace_release/fpol_ingest /usr/local/bin/
COPY --from=frontend /frontend/dist /app/web
COPY product-fpol/data /app/data
COPY product-fpol/deploy/entrypoint.sh /usr/local/bin/fpol-entrypoint
USER fpol
WORKDIR /app
EXPOSE 8080
ENTRYPOINT ["fpol-entrypoint"]
CMD ["fpol_server", "--host", "0.0.0.0", "--database", "/data/fpol.sqlite", "--web-root", "/app/web"]
+19
View File
@@ -0,0 +1,19 @@
*
!product-fpol
!product-fpol/**
!modulus
!modulus/**
!noto
!noto/**
!ligo
!ligo/**
**/.git
**/build
**/node_modules
**/dist
**/var
**/.env*
**/__pycache__
**/.aws
**/.codex
**/.agents
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
umask 077
exec "$@"
+58
View File
@@ -0,0 +1,58 @@
# Application API
All application responses are JSON `{ "ok": true, "status": 200, "payload": ... }`, or `{ "ok": false, "status": ..., "code": ..., "message": ... }`. The HTTP status agrees with the application status. Health and legacy simulator routes keep Ligo's original envelope. Player APIs return current projections; the development simulator alone exposes snapshot history.
## Authentication
`fpol_session` is a random opaque cookie, HttpOnly, SameSite=Lax, Path=/, with a 30-day lifetime. Only its SHA-256 digest is stored. Production requires `--secure-cookies` and an HTTPS `--origin`. Tokens never appear in HTTP JSON. Every POST needs `Origin` exactly matching `--origin` and `X-Fpol-Request: 1`; authenticated POSTs also need `X-CSRF-Token` from login or `GET /api/auth/me`. There is no cross-origin API/CORS access.
- `POST /api/auth/login`: `username, password`. Returns the user (`id, username, admin, csrf`) and sets the cookie.
- `POST /api/auth/register`: `username, password, invite, team_name`. A valid invitation and available seat are checked again transactionally after hashing; a failed join does not create an orphan account.
- `GET /api/auth/me`: current user and CSRF token.
- `POST /api/auth/logout`: invalidates the session and clears the cookie.
- `POST /api/auth/password`: `current_password, password`. Rotates all sessions, returning a new one.
- `POST /api/auth/reset`: `reset_token, password`. Single-use operator token; revokes all sessions and reset tokens for that account. Sign in afterward.
Usernames have 3–32 ASCII letters, numbers or underscores, case-insensitive. Passwords have 12–256 UTF-8 bytes. Hashes use OpenSSL scrypt N=131072/r=8/p=1, an independent 128-bit salt, and a 512-bit result. Two hashing workers serve at most eight in-flight jobs, off the HTTP loop. Login attempts are transactionally limited to 10 per username and 100 per transport peer in 15 minutes; behind Caddy that peer allowance is shared by all players. Password recovery is CLI-only, with a 30-minute expiry. Database files/backups contain secrets and need private storage.
## Leagues
All routes require a session. League-scoped reads and writes require membership. An administrator has no implicit access to other players' leagues.
| Method / path | Input |
| --- | --- |
| GET `leagues` | none; lists own memberships |
| POST `league/create` | `name, team_name, max_teams`; optional `catalog_id` (latest real catalog by default) |
| POST `league/join` | `invite, team_name` |
| GET `league` | query `league_id`; includes own queue, teams, picks, timer, readiness and recent events |
| POST `team/name` | `league_id, name` |
| POST `league/invite` | commissioner; `league_id`; returns a seven-day bearer invitation; replaces prior invitation |
| POST `league/revoke-invites` | commissioner; `league_id` |
| POST `league/remove` | commissioner; `league_id, team_id`; before draft only; also revokes existing invitations |
| POST `league/name` | commissioner; `league_id, name`; rename without changing frozen rules |
| POST `league/ratings` | commissioner; `league_id, document`; immutable sourced ratings JSON while forming |
| POST `league/settings` | commissioner; `league_id, name, catalog_id, ratings_id` (nullable); before draft only; clears queues |
No public registration, public league directory, or email invitation service exists. An invitation is reusable until the league fills, it expires, it is revoked, or drafting starts. One account owns at most one team per league. The commissioner cannot remove their own team.
## Drafts
- `POST draft/start`, `draft/pause`, `draft/resume`: commissioner, `league_id`.
- `POST draft/queue`: `league_id, candidates` (ordered unique candidate IDs, maximum 200). Other members never receive this queue.
- `POST draft/pick`: `league_id, candidate_id, pick_number, request_id`. Use a unique request ID per attempted pick. Retrying the same body/ID returns the original pick; reusing an ID for another body fails with 409. Candidate and slot exclusivity are database constraints. The draft clock and human picks use the same serialized, transactional path; a pick at or after deadline fails and the timer advances the turn.
The draft starts only with 2+ teams, enough global/per-party supply, ratings for every race, and a complete national catalog plus enabled verified count/outcome coverage for real races. The compiled inventory includes 435 House seats, 35 Senate contests (including FL/OH specials), and 36 governor races. The practice pool bypasses real-data gates only under `--allow-demo`.
## Candidates and standings
- `GET catalogs`: catalog/rating summaries, no snapshot history. Demo summaries require `--allow-demo`.
- `GET candidates`: optional `league_id` (pins frozen catalog/ratings/ownership), `catalog_id`, `search`, `office`, `state`, `affiliation`, `available=true`, `offset`, `limit` (1–100). `items` includes source provenance and rating/owner when applicable.
- `GET results`: optional `league_id` or `catalog_id`; current race counts and separate outcomes. Includes received/source/check timestamps and stale status. No observations are invented for missing races.
- `GET standings`: required `league_id`; team rosters, candidate score components, known subtotal, full total only when all 12 scores are known, reporting coverage, provisional flag, and tied competition ranks. Teams with no known scores are unranked.
- `GET admin/sources`: administrator only; current source health, assigned coverage, 50 recent quarantines.
GET parameters are percent-decoded, `+` becomes a space, and duplicate/malformed parameters are rejected by the opted-in Ligo query parser. HTTP requests are capped at 64 KiB. There is no remote admin import/reset/SQL endpoint. `fpol_admin app-request FILE|-` is a **trusted local test/operator interface** that may return session secrets and accepts an injected clock; it is never an HTTP route.
## Persistence
Migration `001_documents` stays unchanged. `002_leagues` adds accounts, sessions, invites, leagues, teams, draft clocks, picks, idempotency commands, events, source configuration, raw payloads, observations, current projections, coverage and quarantine. SQLite WAL, foreign keys and a busy timeout are enabled. One application DB worker owns each short `BEGIN IMMEDIATE` transaction. Hashing and network polling happen outside these transactions. The ingestion process uses its own connection to the same volume.
+69
View File
@@ -0,0 +1,69 @@
# Snapshot and API contract, version 1
The backend is C++23 using Ligo HTTP and Noto/SQLite. `fpol_admin` and HTTP routes call the same domain functions. SQLite migration `001_snapshots` stores immutable JSON documents with typed metadata, catalog foreign keys and an import-time validator. This deliberately small persistence model preserves source fields and history; normalized league/draft/trade tables come with multiplayer.
## Import format
See the runnable documents in [data/demo](../data/demo). All documents require:
- `schema_version: 1`, a unique `id`, `kind` (`catalog`, `ratings`, `results`), `mode` (`demo`, `real`), `label`, and `as_of`.
- `source: {name, url, retrieved_at}`. Real sources require an HTTP(S) URL. Only demo sources may use `fixture:` URLs.
- UTC timestamps in `YYYY-MM-DDTHH:MM:SSZ` form. An adapter must normalize provider offsets, never invent observation times. `as_of` is the source observation time, `retrieved_at` the collection time. Older snapshots imported later keep their earlier position.
A catalog has a `complete` boolean, `races` and `candidates`. Each race needs `id`, `name`, `office` (`house`, `senate`, `governor`), two-letter `state`, and `round`. Each candidate needs `id`, `race_id`, `name`, `affiliation` (`democrat`, `republican`, `other`), `qualified: true`, `ballot_lines`, `provider_ids`, and `source`. IDs describe candidacies, not a person's name. Provider mappings are separate from those IDs. A provider ID may identify only one candidate. Qualification and fusion reconciliation are curator responsibilities; the validator checks structure, identities and explicit qualification, not external truth.
Ratings have `catalog_id` and `entries: [{race_id, rating}]`, where `rating` is `safe`, `likely`, `lean`, or `toss_up`. Missing entries stay unknown. New rating editions receive new IDs; a preview references exactly one edition. No draft exists yet to enforce a league's pre-draft snapshot selection.
Results have `catalog_id` and `entries`, each containing:
```json
{
"race_id": "demo-house-01",
"round": "general",
"status": "reporting",
"total_votes": 1000,
"winner_id": null,
"votes": [
{"candidate_id": "demo-house-01-1", "votes": 510},
{"candidate_id": "demo-house-01-2", "votes": 490},
{"candidate_id": "demo-house-01-3", "votes": 0},
{"candidate_id": "demo-house-01-4", "votes": 0}
]
}
```
Statuses: `reporting`, `called`, `certified`, `pending_round`. Only called/certified rows have `winner_id`. `total_votes` may be null; candidate counts are nonnegative integers bounded at one billion, their sum cannot exceed the total. Totals may include nondraftable write-ins; those still belong in the vote-share denominator. An absent candidate row is unknown, not zero. Each document is a complete snapshot of its included race data, not a delta: omitted races do not inherit previous votes. Preserve per-race source timestamps as additional fields when a future adapter combines sources; the current fixtures use one observation time per document.
Duplicate races, duplicate candidate counts, foreign-race candidates, conflicting modes, unknown references and mismatched rounds are rejected. Combine fusion ballot counts explicitly upstream, retaining original evidence. Missing or pending-round scores are returned as null. Runoff/RCV progression is a launch blocker, not inferred from reporting status.
An identical repeat import is a no-op; conflicting content under the same ID fails. Corrections get a new ID and observation time, including call retractions. A single document insertion is atomic. Multi-file CLI imports commit each file independently in argument order, so import catalogs before dependent snapshots. Persisted history is never updated by the importer; replay requires no rollback writes. Administrators should run one importer at a time; simultaneous identical imports can produce a uniqueness error that is safe to retry.
## Scoring and roster semantics
`(100 × candidate_votes / total_votes + awarded_win_bonus) × race_multiplier`.
The awarded bonus is 50 only for the explicit called/certified winner; other candidates receive zero. Multipliers are Safe 1.00, Likely 1.10, Lean 1.25, Toss-up 1.50. Integer arithmetic rounds each candidate half up to two decimals before summing. More votes for a candidate with opponents' counts fixed cannot reduce their score. Shares can still fall as opponents receive votes; corrections can lower totals or retract bonuses.
Every office has one D, R, Other and Flex slot. Flex accepts any affiliation within that office. A candidacy may occupy only one roster slot; opponents may both be rostered. Partial rosters are valid for exploration but have no complete total. Cross-team ownership, draft locks and trade enforcement belong to the multiplayer milestone.
`leader_preview_points` is hypothetical: only an untied leader with all catalog candidate rows present and all votes accounted for receives a hypothetical bonus, and an explicit call always takes precedence. It is separate from awarded points. A tied or incompletely reported lead has a null preview; `unavailable_leader_previews` counts gaps, and the preview subtotal only includes available previews. Missing scores do not become zero; `known_subtotal` sums only available scores and `missing_scores` reports gaps. `total` exists only when all twelve slots have valid, available scores. A complete total can still be provisional during reporting.
## HTTP
All responses use Ligo's `{ok: true, payload: ...}` or `{ok: false, code, message}` envelope. Invalid input is HTTP 400; roster eligibility errors are a successful validation response with `valid: false`. Unknown routes are 404. No import/write/admin route is exposed.
| Method / path | Request | Payload |
| --- | --- | --- |
| `GET /api/health` | None | `{status: "ok"}` process liveness |
| `GET /api/rules` | None | Version, slots, multipliers, formula and rounding |
| `GET /api/catalog` | None | `{documents: [...]}`, ordered by `as_of`, then ID |
| `POST /api/capacity` | `{catalog_id, players}` | Per-office party and total shortages |
| `POST /api/score-preview` | `{catalog_id, ratings_id, results_id, roster: [{slot, candidate_id}]}` | Validation, breakdown, baseline and leader preview |
The complete document response is intentionally suitable for this small prototype; pagination/indexed views will be needed as real replay history grows. Exact routes have no query-string normalization; the browser filters locally. Imported data becomes visible on the next request without restarting. The browser loads its catalog snapshot on page load; refresh after imports. No authentication is implemented yet; use local development until the multiplayer access controls exist.
## Compatibility with league scoring
The APIs and immutable documents above are the original **v1 development simulator**. Their `pending_round` preview behavior remains unchanged for replay compatibility. They are available over HTTP only with `--allow-demo`; CLI imports/previews remain available to the local operator.
Private leagues use the [application API](application-api.md) and separate [count/outcome observations](ingestion.md). Their frozen rule version is `v2-november-share`: November first-round/first-choice share continues to score during a pending runoff, and only the eventual winner bonus follows later-round outcomes. Do not use a v1 preview total as a v2 league standing. Commissioners can import a sourced ratings document through `POST /api/league/ratings` while a league is forming; it uses the existing immutable ratings schema and must reference that league's selected catalog.
+51
View File
@@ -0,0 +1,51 @@
# Data feasibility — checked 2026-10-04
**Real-league launch is blocked on national candidate coverage, frozen ratings, and verified automatic results/calls.** The prototype is usable with synthetic data. No live provider adapter or polling service is enabled, and no historical scoring backtest is claimed.
## Scope and evidence
The [USPS election calendar](https://about.usps.com/postal-bulletin/2026/pb22694/html/cover_004.htm) lists November 3, 2026, with 435 House seats, 35 Senate seats and 36 state governorships. This milestone assumes the 50 states, including safe and uncontested races. Territories, nonvoting delegates and separate special House contests need an explicit scope decision before a national catalog is frozen. The [150-row matrix](../data/coverage_2026.csv) distinguishes offices without a scheduled election from missing or unverified data. It is an office/state checklist, not a verified seat-by-seat national manifest.
The [Michigan official general-election list](https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport) was readable. Its 13 House races, Senate race and governor race were manually transcribed into [a 73-candidate snapshot](../data/real/mi-2026-10-04.json). Entries marked DISQ were excluded. Governor tickets use the gubernatorial candidate. Provenance dates have day precision; midnight UTC is a storage convention. The source can change: independently reconcile the transcription, ballot qualification and replacements before a draft. Empty provider mappings mean they have not been matched to any results feed. This catalog is deliberately `complete: false`.
[FEC information](https://www.fec.gov/introduction-campaign-finance/election-results-and-voting-information/) is useful for retrospective federal results; campaign filings are not proof of general-election ballot qualification. State election offices remain the catalog authority.
## Provider findings
| Source | Verified evidence | Still unverified / missing |
| --- | --- | --- |
| [civicAPI documentation](https://civicapi.org/api-documentation) | Advertises free, unauthenticated real-time results and calls; documents a v2 API base | Concrete race endpoint, response schema, 2026 office/state coverage, stable candidate IDs, call provenance, timestamp and correction behavior |
| [Open America API contract](https://openamerica.io/elections/api/) | Documents a free results endpoint, 2024 replay and 22-state coverage | Live payload retrieval, individual office/seat coverage and identity reconciliation; no calls or pre-election candidate catalog |
| State election offices | Michigan catalog retrieved | National catalog and automatic feed coverage |
| Commissioner ratings import | Versioned, sourced snapshots supported | No real pre-draft ratings imported; no dated historical rating dataset verified |
Open America documents `https://openamerica.io/elections/results.json?year=2026&office=House`; substitute Senate/Governor or year 2024. Its `final` flag concerns precinct reporting, not certification. Candidate names and parties require explicit internal-ID matching. Follow its 60-second polling guidance, conditional requests, gzip, attribution and backoff. State inclusion does not guarantee every office or uncontested race. Outside the documented 22 states, coverage is missing. Calls are missing everywhere.
Attempts to open provider JSON endpoints through the available research tools returned no usable payload. civicAPI `/api/v2/status`, `/api/v2/races`, and the API root were exploratory guesses, **not verified endpoint contracts**. This is not evidence of a provider outage. Shell networking is restricted here. No provider's transport, election-night reliability or correction behavior has been validated end to end.
## Capacity findings
The C++ capacity endpoint checks 2–12 players. For **each office**, twelve teams need at least 12 Democrats, 12 Republicans, 12 Others and 48 distinct candidates overall. The total requirement reserves enough candidates for Flex after the affiliation slots are filled. These conditions are sufficient for these disjoint office/affiliation constraints; they say nothing about data coverage.
| Imported catalog | House D / R / Other / total | Senate | Governor |
| --- | --- | --- | --- |
| Synthetic | 12 / 12 / 24 / 48 | 12 / 12 / 24 / 48 | 12 / 12 / 24 / 48 |
| Michigan partial | 13 / 13 / 36 / 62 | 1 / 1 / 4 / 6 | 1 / 1 / 3 / 5 |
The fictional catalog supports every allowed league size. Michigan alone cannot fill two complete rosters because Senate and Governor each have only one D and one R. **Nationwide feasibility is unknown**, not disproven. Obtain the remaining qualified candidates before deciding whether the Other slots work for twelve players. Do not silently relax the roster rules.
## Gates before multiplayer launch
1. Reconcile every in-scope seat and qualified candidate, including fusion lines, independents, uncontested seats and replacements. Use one candidacy ID per candidate/race; preserve the original ballot labels and commissioner-assigned roster affiliation. Never draft aggregate write-in buckets.
2. Capture actual provider responses and retain their URL, retrieval time and payload. Confirm candidate-level IDs or reviewed mappings, vote denominators, timestamps, rounds, calls/retractions and corrections. A party leader or 100% precincts is never a call.
3. Obtain sourced, dated ratings for every race; freeze the selected immutable snapshot before drafting. Unrated races block that gate; they do not default to Safe.
4. Verify real capacity for the selected player count, then rehearse automatic polling and replay with outage/staleness handling. Free automatic coverage is a launch requirement. Resolve unsupported states and calls before promising a complete election-night experience.
5. Settle runoff and ranked-choice scoring policy. The current importer rejects a different round for the same catalog race and `pending_round` remains unscored. A later round must not be added to an earlier count. Do not launch affected races until provider semantics and final-round handling are specified and tested.
Commissioner JSON imports make the foundation reviewable; they are not the planned election-night automation. If free sources cannot meet these gates, bring that concrete limitation back for a product decision rather than introducing a paid dependency or silently narrowing the game.
## League milestone update — October 4, 2026
The combined `data/real/us-2026-10-04-partial.json` now includes **115 candidates / 30 contests**: Michigan's existing 73/15 plus North Carolina's 42 candidates across 14 House districts and one Senate contest. NC facts were reviewed from [the official state-only November candidate list](https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf), pages 1–3, report dated September 21. No residential/contact details were copied. Retrieval timestamps have day precision; midnight is a date placeholder. The remaining 48 states still need candidate imports; real ratings and live coverage remain incomplete.
A national inventory now records 506 scheduled November contests (435 House, 35 Senate including the FL/OH specials, 36 governor). It supplements the 150-row state/office matrix and guards draft readiness. Candidate source watches, Open America normalization, and a reviewed official JSON integration contract are implemented, but **no provider capture is verified**. civicAPI remains capture-only pending an actual response contract. See [ingestion](ingestion.md) for activation requirements and the settled November first-choice share policy.
+73
View File
@@ -0,0 +1,73 @@
# Hosting, backup and recovery
The repository contains a deployment configuration; no host has been selected or deployed. HTTP/browser/container tests must pass on a normal host before using it with friends. Real drafting remains data-gated separately.
## Build and bootstrap
Keep `product-fpol`, `modulus`, `noto`, and the updated `ligo` repositories beside each other. Compose uses the parent directory as its build context, excludes caches/secrets, exports those local Conan recipes, and builds a release C++ image with GCC 12. The frontend uses a separate Node build stage. First build requires internet access for base images and dependencies.
```sh
cp .env.example .env
# Set FPOL_DOMAIN to a hostname whose DNS points at your host; no scheme/path.
docker compose build
# Bootstrap migrations, the partial catalog, and an administrator before serving.
docker compose run --rm --no-deps app fpol_admin --database /data/fpol.sqlite \
import /app/data/real/us-2026-10-04-partial.json
docker compose run --rm --no-deps app fpol_admin --database /data/fpol.sqlite create-user YOUR_USERNAME
docker compose up -d
```
Caddy owns public ports 80/443 and obtains HTTPS certificates. The app's 8080 port is internal only. Both app and worker use UID 10001 and the persistent `fpol_data` volume. Caddy certificates/config have their own volumes. The entrypoint uses umask 077. Health checks call `/api/health`; the worker starts after the app is healthy. No demo mode is enabled. Source activation is explicit; an idle ingestion worker invents no data.
Log in, create a league, and share its private invitation link. To reset a password:
```sh
docker compose exec app fpol_admin --database /data/fpol.sqlite reset-link USERNAME
```
Privately give that user `https://YOUR_HOST/#reset/TOKEN`. The link expires in 30 minutes and works once. Do not paste passwords, invitations, session tokens or reset links into public logs/issues.
## Source setup
Use [the ingestion guide](ingestion.md). Copy reviewed configuration files into the container or mount an operator-controlled directory. Configure, probe, inspect source status and archived payload/mappings, then activate. Production startup never auto-activates sample mappings. Both coverage and the candidate inventory must be complete before real draft start.
## Online backup
Use SQLite's online backup API through Noto while the service is running. Choose a new filename each time; the command refuses to overwrite one.
```sh
docker compose exec app fpol_admin --database /data/fpol.sqlite \
backup /data/backups/fpol-2026-10-04.sqlite
docker compose cp app:/data/backups/fpol-2026-10-04.sqlite ./fpol-2026-10-04.sqlite
chmod 600 ./fpol-2026-10-04.sqlite
```
Store a protected off-host copy. Include the deployed source revisions and source configuration files. The database backup contains accounts, sessions, invites, frozen league data, picks, raw source payloads and observation history. Do not copy only the live SQLite main file while WAL writes are active.
## Restore
Stop **both** writers. Keep the original database and sidecars as a rollback set. Restore to a stopped app container; adapt the filename to your chosen backup. Do not restore a backup over a running database.
```sh
docker compose stop app ingest
# The existing container can receive files while stopped.
docker compose cp ./fpol-2026-10-04.sqlite app:/data/restore.sqlite
docker compose run --rm --no-deps --user root app sh -c 'chown 10001:10001 /data/restore.sqlite && chmod 600 /data/restore.sqlite'
# Use the image's unprivileged account, backed by the same volume.
docker compose run --rm --no-deps app sh -c '
set -eu
test -f /data/restore.sqlite
mkdir /data/before-restore
for name in fpol.sqlite fpol.sqlite-wal fpol.sqlite-shm; do
if test -f "/data/$name"; then mv "/data/$name" /data/before-restore/; fi
done
cp /data/restore.sqlite /data/fpol.sqlite
chmod 600 /data/fpol.sqlite
fpol_admin --database /data/fpol.sqlite rules >/dev/null
'
docker compose up -d app ingest
```
The ownership step affects only the staged backup, so UID 10001 can read it. Verify SQLite integrity on the offline backup (`PRAGMA integrity_check`), source health, memberships, and the last draft pick. Restored active drafts pause automatically; commissioners resume with a fresh 120-second turn. Restoring an older backup also restores old session/reset state; consider revoking sessions and reset tokens via trusted local SQLite administration after recovery.
Migrations are forward-only. Back up before upgrades; reverting application binaries after a schema change requires restoring a compatible database backup too. Never use `docker compose down -v` to restart this app: it deletes persistent volumes.
+88
View File
@@ -0,0 +1,88 @@
# Election ingestion
`fpol_ingest` is a separate C++ process using Ligo's raw HTTPS client and Noto/SQLite. It supports conditional ETag/Last-Modified requests, a 25-second network deadline, a 16 MiB response limit, exponential failure backoff up to one hour, immutable changed-payload archives, immutable observations, quarantine, and current projections. Enabled count/outcome feeds are polled approximately every 60 seconds; candidate watches daily. Responses request identity encoding. No browser scrapes providers directly.
## Honest readiness
As of the implementation session, **no live provider is verified**. The environment cannot open sockets or resolve outbound DNS. Open America's published schema informed a tested adapter; synthetic replay tests are not transport validation. civicAPI has only a capture-only probe: its race/candidate contract still needs a real successful response before a normalizer can be written. The `official-json` adapter is a normalized integration contract, not a claim that arbitrary state websites already emit it. Additional state-specific CSV/XML/JSON adapters and reviewed PDF imports are still required for national coverage.
Open America documents counts from 22 states, no pre-election candidate catalog, and no race calls. Its `final`, leader, precinct percentage, and candidate `w` fields never award a winner bonus. Documentation: [Open America election API](https://openamerica.io/elections/api/). Attribution in the UI should retain the feed URL and timestamps; the source is official state election offices via Open America.
## Configure, capture, review, activate
```sh
fpol_admin --database var/fpol.sqlite import data/real/us-2026-10-04-partial.json
fpol_ingest --database var/fpol.sqlite configure data/sources/openamerica-nc-house.json
fpol_ingest --database var/fpol.sqlite probe openamerica-nc-house
fpol_ingest --database var/fpol.sqlite status
# Inspect the archived raw response and exact candidate/round mappings first.
fpol_ingest --database var/fpol.sqlite activate openamerica-nc-house
fpol_ingest --database var/fpol.sqlite run
```
Configuration starts disabled and unverified. The sample NC mappings use official ballot spellings as **proposals**; they have not been matched to an actual provider capture. Update exact aliases after review. A configuration change disables the source and clears its per-race verification checks. Activation requires a validated live observation for **every configured race/role**, not merely an HTTP 200 or one race in the response. Before election night, an empty results payload cannot satisfy this requirement; real draft readiness may need a provider that offers verifiable race coverage before counts arrive.
`probe` fetches even a disabled source. `once` polls only enabled sources whose next check is due. `disable ID` stops scheduled polling. `activate ID` selects this provider for each configured role/race, replacing that assignment; do this deliberately, with reviewed data. Existing selected counts are never added to another provider's counts.
Raw changed responses are stored in `source_payloads` keyed by source plus content hash. Use the local SQLite client or a backup to inspect/export them; they are not served through player APIs. `source_checks` records only successful live validations. `observations` retains normalized records, `current_observations` selects the current record, `coverage` assigns one count source and one outcome source per contest, and `source_quarantine` records rejected responses and their archive ID.
## Adapter configuration
A configuration has `id`, `kind`, HTTPS `url`, `catalog_id`, and `races`. Each assignment has an internal `race_id` and `role` (`counts` or `outcome`). Standard HTTPS hostnames on port 443 are supported; redirects are reported as HTTP errors for operator review.
For `openamerica`, also supply `office` (`House`, `Senate`, `Governor`), `counts_until` in UTC, and each assignment's `provider_id` plus a `candidates` object mapping exact `name|party-code` to stable candidacy IDs. Explicit `null` mappings can exclude an aggregate write-in row; unknown rows quarantine the response. Separate fusion ballot lines can map to the same candidacy and their votes are summed once. Duplicate provider rows, wrong-race IDs, negative counts and impossible totals are rejected. Do not map different people together or assign aggregate write-ins a roster slot.
Open America does not expose a round identifier. The configured cutoff prevents a later election from silently overwriting November counts. Review each state's feed semantics, including ranked-choice reporting; do not activate an adapter if it cannot supply first-choice counts. After the cutoff, reviewed first-round corrections need an official-source adapter with explicit round metadata. Sample NC cutoffs are conservative operational bounds, not a national runoff calendar.
The `official-json` endpoint returns:
```json
{
"schema_version": 1,
"observations": [
{
"race_id": "2026-nc-senate-general",
"kind": "counts",
"round": "november_first",
"source_time": "2026-11-04T03:00:00Z",
"total_votes": 1000,
"votes": [
{ "candidate_id": "2026-nc-senate-general-roy-cooper", "votes": 480 }
]
},
{
"race_id": "2026-nc-senate-general",
"kind": "outcome",
"round": "general",
"source_time": "2026-11-04T04:00:00Z",
"status": "reporting",
"winner_id": null
}
]
}
```
**The numbers above are schema examples, not election results.** `source_time` may be null when unknown; use UTC `Z` or `+00:00` otherwise. Source timestamps and receipt/check times remain separate. Counts may contain zero values, but zero total or a missing candidate produces an unknown score. Candidate totals cannot exceed the reported race total; the total may include explicitly excluded write-ins. Only `november_first` counts are accepted. A later runoff/RCV outcome may use its own round identifier without altering those counts.
Outcome status is `reporting`, `pending_round`, `called`, or `certified`. Only called/certified outcomes have a `winner_id`. A newer reporting/pending record retracts a provisional call. A certified outcome takes precedence over subsequent provisional records; a newer certified correction may change the winner. Older source timestamps are archived without moving the current projection backward. Missing races do not erase previous data. Failed or ambiguous responses preserve the last good observation and mark source health stale.
## Candidates and reviews
```sh
fpol_ingest configure data/sources/nc-candidate-watch.json
fpol_ingest watch nc-candidate-watch
fpol_ingest configure data/sources/civicapi-status.json
fpol_ingest probe civicapi-status
```
`candidate-watch` captures changed public source files daily for human review; it does not blindly parse PDF names or silently change a frozen draft pool. Import a new validated catalog ID after reviewing changes. Preserve stable candidacy IDs across spelling/party-line updates; merge fusion lines; exclude aggregate write-ins. Individually qualified write-in candidates need their own reviewed identity and eligibility evidence. Old catalog documents and draft snapshots remain immutable.
[The inventory](../data/contests_2026.json) covers the 50 states' scheduled November House/Senate/governor contests, including safe and uncontested seats, with FL/OH special Senate contests distinct. Round data stays separate from contest identity. House at-large seats use internal district `01`; map providers' `00` explicitly. The inventory is a scope checklist, not evidence that candidate or result coverage is complete.
## Replay without overstating verification
```sh
fpol_ingest replay SOURCE_ID captured-or-fixture.json
```
Replay archives and validates the file but never grants live verification. It can publish observations only into an explicitly fictional `mode: demo` catalog; real catalogs require network observations. Demo replays can assign empty demo coverage slots so practice standings can be exercised. Replaying an old file never unlocks real drafting. The first milestone's immutable `results` snapshots and `score-preview` remain a separate v1 lab contract; the league scorer uses `v2-november-share`.
+48
View File
@@ -0,0 +1,48 @@
# Verification — league milestone, 2026-10-04
## Passed locally
- C++23 Debug and Release builds of `fpol_server`, `fpol_admin`, and `fpol_ingest` with the available **GCC 16.2.1** host compiler.
- 25 original domain/persistence tests: immutable imports, reopening SQLite, migrations, capacity, eligibility, score rounding, mode isolation, missing data, replay corrections and original round behavior.
- 19 application/ingestion tests against the production C++ executables and real SQLite databases: authentication/CSRF/rate limits, invite registration/capacity/expiry/revocation, private league permissions, password change/recovery/session revocation, partial real catalog paging, private queues, snake order, timeouts, pause/restart, simultaneous idempotent picks, cross-league ownership, complete 12-team/144-pick drafts, global party-slot capacity, first-round/runoff separation, retractions/corrections/older observations, quarantine, fusion-line aggregation, unverified-feed refusal, per-race activation gates, sourced ratings permissions/immutability/freeze, online backups, and candidate-watch content change detection.
- Strict TypeScript checking and Vite production build, including the separate lab entry point.
- Shared Modulus C++/Python formatting, `git diff --check`, and canonical-bootstrap comparison.
- Updated Ligo library and its test executable compile. The new socket-independent response/header metadata test passes.
- Compose configuration parses successfully with `FPOL_DOMAIN=game.example docker compose config --quiet`.
- The combined real candidate import succeeds: 115 candidates, 30 contests, explicitly partial. The national inventory has 506 contests.
The full Debug CTest run passed 25 domain plus the then-current 18 application tests. After the final candidate-watch regression was added, all 19 application tests passed separately in Debug. Release passed the 25 domain and 18 application tests, then the added watch regression separately after rebuilding. These are local fallback checks, not a successful run of the configured GCC 12 pipeline.
## Blocked checks — not passed or skipped
The full Debug CTest suite remains **red**: two server tests (help and invalid startup) pass, while seven HTTP/socket checks cannot execute because socket creation is rejected with `Operation not permitted`. These include the new cookie/Secure/HttpOnly/origin/CSRF/query checks. Ligo's new raw HTTP wire test also stops when its server cannot bind.
The real-browser smoke test was attempted and stops at Node's `listen EPERM` before Chromium can exercise the app. Therefore no interactive/visual browser pass or screenshots are claimed. The checked-in Node/CDP test uses two isolated browser sessions and the actual C++ server; CI now invokes it after frontend/backend builds.
A real Open America probe was attempted. It failed DNS resolution (`Host not found … try again later`) and left the source **disabled, unverified, with no successful capture**. No live election results or national feed coverage are claimed. civicAPI remains a capture-only probe pending an actual response contract. Provider fixtures used by tests are synthetic and never count as verification.
Docker's daemon/socket is inaccessible and GCC 12 is not installed on this host. The Docker image, Caddy startup/certificate issuance, source-based Conan helper on a fresh networked machine, and the full GCC 12 debug/release/HTTP/browser pipeline still need execution outside this environment. Compose parsing is not a container deployment test.
## Build context and dependency handoff
Fallback builds use ignored `build/kickoff_debug`, `build/kickoff_release`, and `build/verification_*` directories, with locally built shared libraries and cached Boost/OpenSSL/SQLite. Release application compilation maps cached dependency configurations to Debug; it is an optimized application check, not a fully optimized dependency build or benchmark.
**The sibling Ligo repository has required source changes** in its service/HTTP interfaces and implementation, plus tests. They add optional query parsing, request headers, custom response status/headers, and raw HTTP client exchange. Default query handling and existing aggregate initialization remain compatible. Carry those library changes with this application change. The package-based CI needs that matching Ligo revision published/available before it can pass; `tools/build_workspace.py` and the Docker source build use the local sibling revision without publishing.
No deployment, library publishing, Git commit, or push was performed. Existing local accounts/passwords were not created or replaced. Test users live only in temporary databases; the failed provider probe is in ignored `build/source-probe.sqlite`.
## Reproduce
On a normal networked host, use the [README](../README.md) and [deployment guide](deployment.md). In this existing offline workspace, the configured fallback paths are:
```sh
cmake --build build/kickoff_debug
cmake --build build/kickoff_release
python3 tests/test_core.py build/kickoff_debug/fpol_admin
python3 tests/test_application.py build/kickoff_debug/fpol_admin build/kickoff_debug/fpol_ingest
ctest --test-dir build/kickoff_debug --output-on-failure
npm --prefix frontend run build
node tests/browser.mjs build/kickoff_debug/fpol_server
```
Do not interpret the socket-independent successes as substitutes for the blocked HTTP/browser tests or real source verification.
+2 -30
View File
@@ -8,36 +8,8 @@
<title>Fantasy Politics</title>
</head>
<body>
<header class="site-header">
<a class="wordmark" href="/" aria-label="Fantasy Politics home">
<span class="monogram" aria-hidden="true">fp.</span>
<span>Fantasy Politics</span>
</a>
<span class="edition">The 2026 edition</span>
</header>
<main>
<section class="intro" aria-labelledby="page-title">
<p class="eyebrow"><span aria-hidden="true">★</span> Friends. Friendly competition. The midterms.</p>
<h1 id="page-title">Your picks.<br />Your party.<br /><em>Bragging rights.</em></h1>
<p class="description">A fantasy-style game for our own little political league. Build a party of candidates, follow the 2026 US midterms, and see whose picks come out on top.</p>
<p class="status"><span class="status-dot" aria-hidden="true"></span> The league is taking shape. More soon.</p>
</section>
<section class="overview" aria-labelledby="overview-title">
<div class="overview-heading">
<h2 id="overview-title">The idea</h2>
<span>On the drawing board</span>
</div>
<ol class="steps">
<li><span class="step-number" aria-hidden="true">01</span><h3>Draft your party</h3><p>Pick your candidates when the league gets together for the draft.</p></li>
<li><span class="step-number" aria-hidden="true">02</span><h3>Make your moves</h3><p>Trade with friends as the election season unfolds.</p></li>
<li><span class="step-number" aria-hidden="true">03</span><h3>Watch it play out</h3><p>Real-world candidate performance will decide the final standings.</p></li>
</ol>
</section>
</main>
<footer><span>A game among friends.</span><span>2026 US midterm elections</span></footer>
<div id="app"><p role="status">Loading Fantasy Politics…</p></div>
<noscript>Enable JavaScript to play with your league.</noscript>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>FPOL · Development lab</title></head><body><div id="app"></div><script type="module" src="/src/lab.ts"></script></body></html>
+25
View File
@@ -0,0 +1,25 @@
import { api } from './api';
import { element, escape, time } from './ui';
import type { Catalogs } from './types';
interface Source {
id: string;
kind: string;
enabled: number;
verified: number;
last_checked: number | null;
last_success: number | null;
last_error: string | null;
}
export async function adminPage(signal: AbortSignal) {
const [data, catalogs] = await Promise.all([api<{
sources: Source[];
quarantine: {
source_id: string;
reason: string;
retrieved_at: number;
}[];
}>('admin/sources', undefined, signal), api<Catalogs>('catalogs', undefined, signal)]);
if (signal.aborted)
return;
element('#page').innerHTML = `<h1>Source diagnostics</h1><p class="muted">Operator tools. Catalog imports, source configuration, and password recovery run through the local C++ command-line tools.</p>${catalogs.allow_demo ? '<p><a href="/lab.html">Open the development simulator ↗</a></p>' : ''}<section class="panel"><h2>Feeds</h2>${data.sources.map(s => `<article class="candidate"><strong>${escape(s.id)}</strong><p>${escape(s.kind)} · ${s.verified ? 'verified' : 'unverified'} · ${s.enabled ? 'enabled' : 'disabled'}</p><p>Checked ${time(s.last_checked)} · succeeded ${time(s.last_success)}</p>${s.last_error ? `<p class="error">${escape(s.last_error)}</p>` : ''}</article>`).join('') || '<p>No feeds are configured. Live coverage is not yet verified.</p>'}</section><details class="panel"><summary>Quarantined responses (${data.quarantine.length} recent)</summary>${data.quarantine.map(q => `<p>${escape(q.source_id)} · ${time(q.retrieved_at)}<br>${escape(q.reason)}</p>`).join('') || '<p>No quarantined responses.</p>'}</details>`;
}
+28
View File
@@ -0,0 +1,28 @@
export interface User {
id: string;
username: string;
admin: number;
csrf: string;
}
export let user: User | null = null;
export function setUser(value: User | null) { user = value; }
export class ApiError extends Error {
constructor(message: string, public status: number) { super(message); }
}
let controller = new AbortController();
export function beginPage() { controller.abort(); controller = new AbortController(); return controller.signal; }
export async function api<T>(action: string, input?: object, signal?: AbortSignal): Promise<T> {
const response = await fetch(`/api/${action}`, { method: input ? 'POST' : 'GET', credentials: 'same-origin', signal,
headers: input ? { 'Content-Type': 'application/json', 'X-Fpol-Request': '1', 'X-CSRF-Token': user?.csrf || '' } : {}, body: input ? JSON.stringify(input) : undefined });
const body = await response.json();
if (!response.ok || body.ok === false)
throw new ApiError(body.message || 'The request could not be completed.', response.status);
return body.payload as T;
}
export function query(action: string, values: Record<string, string | number | null | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values))
if (value != null && value !== '')
params.set(key, String(value));
return `${action}?${params}`;
}
+25
View File
@@ -0,0 +1,25 @@
import { api, setUser, user } from './api';
import type { User } from './api';
import { element, escape, field, go, submit } from './ui';
export function authPage(path: string[], refresh: () => void) {
const invite = path[0] === 'join' ? path[1] || '' : '';
const reset = path[0] === 'reset' ? path[1] || '' : '';
element('#page').innerHTML = `<section class="auth"><p class="eyebrow">A little friendly competition</p><h1>${reset ? 'Reset your password' : invite ? 'You’re invited.' : 'Election night, together.'}</h1><p>Draft a fantasy political party. Follow the races. Play with your friends.</p><div class="panel"><h2>${reset ? 'Choose a new password' : 'Sign in'}</h2><form id="signin">${reset ? '' : field('Username', 'username', '', 'autocomplete="username" pattern="[a-zA-Z0-9_]{3,32}"')}${field('Password', 'password', '', 'type="password" autocomplete="' + (reset ? 'new-password' : 'current-password') + '" minlength="12" maxlength="256"')}<button type="submit">${reset ? 'Reset password' : 'Sign in'}</button></form><p class="muted">${reset ? 'Your reset link can be used once.' : 'Need a password reset? Ask the person hosting your league for a reset link.'}</p></div>${invite ? `<details class="panel" open><summary>New here? Create an account</summary><form id="register">${field('Username', 'username', '', 'autocomplete="username" pattern="[a-zA-Z0-9_]{3,32}" minlength="3" maxlength="32"')}${field('Password · at least 12 characters', 'password', '', 'type="password" autocomplete="new-password" minlength="12" maxlength="256"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}<button type="submit">Join your friends</button></form></details>` : !reset ? '<p class="muted">New players join through a league invitation.</p>' : ''}</section>`;
submit('#signin', async (data) => {
if (reset) {
await api('auth/reset', { reset_token: reset, password: data.get('password') });
setUser(null);
go('leagues');
refresh();
return;
}
setUser(await api<User>('auth/login', Object.fromEntries(data)));
refresh();
});
if (invite)
submit('#register', async (data) => { setUser(await api<User>('auth/register', { ...Object.fromEntries(data), invite })); go('leagues'); refresh(); });
}
export function accountPage(refresh: () => void) {
element('#page').innerHTML = `<h1>Your account</h1><p>Signed in as ${escape(user?.username)}</p><section class="panel narrow"><h2>Change password</h2><form id="password">${field('Current password', 'current_password', '', 'type="password" autocomplete="current-password"')}${field('New password', 'password', '', 'type="password" autocomplete="new-password" minlength="12" maxlength="256"')}<button type="submit">Update password</button></form></section>`;
submit('#password', async (data) => { setUser(await api<User>('auth/password', Object.fromEntries(data))); go('leagues'); refresh(); });
}
+45
View File
@@ -0,0 +1,45 @@
import { api, query } from './api';
import { click, element, escape, message, sourceLink } from './ui';
import type { Candidate, CandidatePage } from './types';
export interface CandidateActions {
legal: () => Set<string>;
queued: () => Set<string>;
pick: (candidate: Candidate) => Promise<void>;
queue: (candidate: Candidate) => Promise<void>;
}
export function candidateBrowser(root: HTMLElement, signal: AbortSignal, league?: string, actions?: CandidateActions) {
let offset = 0, sequence = 0;
const pageSize = 30;
root.innerHTML = `<form class="search" role="search"><label class="sr-only" for="candidate-search">Search candidates</label><input id="candidate-search" name="search" type="search" placeholder="Search by name, state, or district" autocomplete="off"><details><summary>Filters</summary><div class="filters"><label>Office<select name="office"><option value="">All offices</option><option value="house">House</option><option value="senate">Senate</option><option value="governor">Governor</option></select></label><label>Party slot<select name="affiliation"><option value="">Any party</option><option value="democrat">Democrat</option><option value="republican">Republican</option><option value="other">Other</option></select></label><label>State<input name="state" maxlength="2" placeholder="e.g. MI"></label>${league ? '<label class="checkbox"><input type="checkbox" name="available" value="true"> Available only</label>' : ''}</div></details></form><p class="muted catalog-note"></p><div class="candidate-list" aria-live="polite"></div><div class="pagination"></div>`;
const form = element<HTMLFormElement>('form', root);
let timer: number | undefined;
async function refresh() {
const ticket = ++sequence;
const values = Object.fromEntries(new FormData(form)) as Record<string, string>;
values.state = values.state?.toUpperCase();
try {
const page = await api<CandidatePage>(query('candidates', { ...values, league_id: league, offset, limit: pageSize }), undefined, signal);
if (signal.aborted || ticket !== sequence)
return;
element('.catalog-note', root).textContent = page.catalog ? `${page.total} candidates · ${page.catalog.mode === 'demo' ? 'Fictional practice pool' : page.catalog.complete ? 'Complete catalog' : 'Partial official catalog · more states to come'}` : 'Candidate data is being prepared.';
element('.candidate-list', root).innerHTML = page.items.length ? page.items.map(c => `<article class="candidate"><div class="candidate-main"><div><strong>${escape(c.name)}</strong><span class="candidate-meta">${escape(c.state)} · ${escape(c.office)} · ${escape(c.affiliation)}${c.owner ? ' · ' + escape(c.owner) : ''}</span></div>${actions ? `<div class="actions"><button class="quiet queue-candidate" data-id="${escape(c.id)}" ${c.owner || actions.queued().has(c.id) ? 'disabled' : ''}>${actions.queued().has(c.id) ? 'Queued' : '+ Queue'}</button><button class="pick-candidate" data-id="${escape(c.id)}" ${!actions.legal().has(c.id) ? 'disabled' : ''}>Draft</button></div>` : `<span class="badge">${c.rating ? escape(c.rating.replace('_', ' ')) : 'Unrated'}</span>`}</div><details><summary>Race & source</summary><p>${escape(c.race_id)} · ${escape(c.party || c.affiliation)}</p><p>Rating: ${c.rating ? escape(c.rating.replace('_', ' ')) : 'Awaiting a sourced rating'}. November first-round share plus a 50-point winner bonus, multiplied by the frozen race rating.</p><p>${sourceLink(c.source.url, c.source.name)} · retrieved ${escape(c.source.retrieved_at)}</p></details></article>`).join('') : '<p class="empty">No candidates match this search.</p>';
element('.pagination', root).innerHTML = page.total ? `<button class="secondary previous" ${offset === 0 ? 'disabled' : ''}>Previous</button><span>${offset + 1}–${Math.min(offset + pageSize, page.total)} of ${page.total}</span><button class="secondary next" ${offset + pageSize >= page.total ? 'disabled' : ''}>Next</button>` : '';
click('.previous', async () => { offset = Math.max(0, offset - pageSize); await refresh(); }, root);
click('.next', async () => { offset += pageSize; await refresh(); }, root);
for (const candidate of page.items)
if (actions) {
click(`.pick-candidate[data-id="${candidate.id}"]`, async () => { await actions.pick(candidate); await refresh(); }, root);
click(`.queue-candidate[data-id="${candidate.id}"]`, async () => { await actions.queue(candidate); await refresh(); }, root);
}
}
catch (error) {
message(error);
}
}
form.addEventListener('submit', event => { event.preventDefault(); offset = 0; void refresh(); });
form.addEventListener('input', () => { offset = 0; clearTimeout(timer); timer = window.setTimeout(() => void refresh(), 250); });
signal.addEventListener('abort', () => clearTimeout(timer));
void refresh();
return refresh;
}
export function candidatesPage(signal: AbortSignal, league?: string) { element('#page').innerHTML = '<p class="eyebrow">Meet the field</p><h1>Candidates</h1><div id="browser"></div>'; candidateBrowser(element('#browser'), signal, league); }
+58
View File
@@ -0,0 +1,58 @@
import { api, query, user } from './api';
import { candidateBrowser } from './candidates';
import { click, element, escape, message } from './ui';
import type { LeagueView } from './types';
export async function draftPage(id: string, signal: AbortSignal) {
let view = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<a class="back" href="#league/${id}">← ${escape(view.league.name)}</a><h1>Draft room</h1>${view.league.catalog_mode === 'demo' ? '<p class="badge">Practice · fictional candidates</p>' : ''}<section class="turn panel" aria-live="polite"><div id="turn-label"></div><div class="clock" id="clock"></div><div id="draft-controls"></div></section><div class="draft-layout"><section><div id="browser"></div></section><aside><section class="panel"><h2>Your private queue</h2><p class="muted">On timeout, the first legal candidate here is picked. Otherwise, a legal candidate is chosen at random.</p><ol id="queue"></ol></section><section class="panel"><h2>Recent picks</h2><ol id="recent"></ol></section><details class="panel"><summary>All rosters</summary><div id="rosters"></div></details></aside></div>`;
let received = Date.now(), poll: number | undefined;
let revision = '';
let drawing = '';
const refreshCandidates = candidateBrowser(element('#browser'), signal, id, { legal: () => new Set(view.league.status === 'drafting' ? view.legal_candidate_ids : []), queued: () => new Set(view.my_team.queue), pick: async (c) => { await api('draft/pick', { league_id: id, candidate_id: c.id, pick_number: view.draft.pick_number, request_id: crypto.randomUUID() }); await refresh(); }, queue: async (c) => { await api('draft/queue', { league_id: id, candidates: [...view.my_team.queue, c.id] }); await refresh(); } });
function draw() {
const status = view.league.status;
const commissioner = view.league.commissioner_id === user?.id;
const picker = view.picker;
element('#turn-label').innerHTML = status === 'forming' ? `<h2>Before the first pick</h2><p>${view.readiness.ready ? 'Everyone is ready. Your commissioner can start the draft.' : view.readiness.problems.map(escape).join(' · ')}</p>` : status === 'drafted' ? '<h2>Draft complete</h2><p>Your party is ready for election night.</p>' : `<p class="eyebrow">Round ${Math.floor(view.draft.pick_number / view.teams.length) + 1} of 12 · Pick ${view.draft.pick_number + 1}</p><h2>${picker?.user_id === user?.id ? 'You’re on the clock' : escape(picker?.name) + ' is picking'}</h2>${status === 'paused' ? '<p>Draft paused. Your commissioner can resume it.</p>' : ''}`;
element('#draft-controls').innerHTML = commissioner ? (status === 'forming' ? `<button id="start" ${view.readiness.ready ? '' : 'disabled'}>Start draft</button>` : status === 'drafting' ? '<button class="secondary" id="pause">Pause draft</button>' : status === 'paused' ? '<button id="resume">Resume draft</button>' : '') : '';
for (const action of ['start', 'pause', 'resume'])
click(`#${action}`, async () => { await api(`draft/${action}`, { league_id: id }); await refresh(); });
element('#queue').innerHTML = view.my_team.queue.length ? view.my_team.queue.map((candidate, index) => `<li><span>${escape(view.candidate_names[candidate] || candidate)}</span><div><button class="quiet up" data-index="${index}" ${index === 0 ? 'disabled' : ''} aria-label="Move ${escape(view.candidate_names[candidate] || candidate)} earlier">↑</button><button class="quiet unqueue" data-index="${index}" aria-label="Remove ${escape(view.candidate_names[candidate] || candidate)} from queue">×</button></div></li>`).join('') : '<li class="muted">Add candidates from the list.</li>';
view.my_team.queue.forEach((_, index) => { click(`.up[data-index="${index}"]`, async () => { const queue = [...view.my_team.queue]; [queue[index - 1], queue[index]] = [queue[index], queue[index - 1]]; await api('draft/queue', { league_id: id, candidates: queue }); await refresh(); }); click(`.unqueue[data-index="${index}"]`, async () => { await api('draft/queue', { league_id: id, candidates: view.my_team.queue.filter((_, i) => i !== index) }); await refresh(); }); });
element('#recent').innerHTML = view.picks.slice(-6).reverse().map(p => `<li><strong>${escape(view.candidate_names[p.candidate_id] || p.candidate_id)}</strong><small>${escape(view.teams.find(t => t.id === p.team_id)?.name)} · ${escape(p.slot)}${p.automatic ? ' · auto' : ''}</small></li>`).join('') || '<li class="muted">The first pick is still ahead.</li>';
element('#rosters').innerHTML = view.teams.map(t => `<details><summary>${escape(t.name)}</summary><ul>${view.picks.filter(p => p.team_id === t.id).map(p => `<li>${escape(view.candidate_names[p.candidate_id] || p.candidate_id)}<small>${escape(p.slot)}</small></li>`).join('') || '<li>No picks yet.</li>'}</ul></details>`).join('');
}
async function refresh() {
const previousQueue = JSON.stringify(view.my_team.queue);
const next = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
view = next;
received = Date.now();
const state = JSON.stringify({ ...view, server_time: 0 });
if (state !== drawing) {
drawing = state;
draw();
}
if (revision !== view.league.status + view.picks.length || previousQueue !== JSON.stringify(view.my_team.queue)) {
revision = view.league.status + view.picks.length;
await refreshCandidates();
}
}
function clock() { if (signal.aborted)
return; const remaining = view.league.status === 'drafting' && view.draft.deadline ? Math.max(0, view.draft.deadline - view.server_time - Math.floor((Date.now() - received) / 1000)) : view.league.status === 'paused' ? view.draft.remaining : null; element('#clock').textContent = remaining == null ? '' : `${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`; }
async function loop() { try {
await refresh();
}
catch (error) {
message(error);
} if (!signal.aborted)
poll = window.setTimeout(() => void loop(), 2000); }
draw();
clock();
const ticker = window.setInterval(clock, 500);
poll = window.setTimeout(() => void loop(), 2000);
signal.addEventListener('abort', () => { clearTimeout(poll); clearInterval(ticker); });
}
+73
View File
@@ -0,0 +1,73 @@
:root { font-family: 'Avenir Next', Avenir, 'Segoe UI', sans-serif; color: #213632; background: #f6f4ee; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; --muted: #596760; --line: #d6dbd2; }
* { box-sizing: border-box; }
body { margin: 0; }
a { color: inherit; text-underline-offset: 3px; }
:focus-visible { outline: 3px solid #98602c; outline-offset: 3px; }
.site-header, main, footer { width: min(1200px, calc(100% - 64px)); margin-inline: auto; }
.site-header { min-height: 94px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); }
.wordmark { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; font-size: 17px; font-weight: 650; letter-spacing: -.03em; }
.monogram { display: grid; place-items: center; width: 43px; height: 43px; border: 1px solid #213632; border-radius: 50%; font-family: Georgia, serif; font-size: 25px; font-weight: 400; padding-bottom: 4px; }
.edition, .eyebrow { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .13em; }
.intro { padding: 56px 0 36px; }
.eyebrow { margin: 0 0 14px; font-weight: 650; }
h1 { margin: 0; font-family: Georgia, serif; font-size: clamp(38px, 5.5vw, 70px); font-weight: 400; line-height: 1.08; letter-spacing: -.05em; }
h1 em { font-weight: 400; color: #67765e; }
.description { max-width: 600px; margin: 22px 0 0; font-size: 17px; line-height: 1.7; color: var(--muted); }
h2 { font: 400 32px Georgia, serif; margin: 0; letter-spacing: -.02em; }
p { line-height: 1.65; }
.small, .muted, small { font-size: 12px; color: var(--muted); }
.workspace { padding: 24px; background: #e9ece3; border: 1px solid var(--line); }
.workspace > label { max-width: 510px; }
label { display: flex; flex-direction: column; gap: 7px; font-size: 12px; font-weight: 600; }
select, input { font: inherit; font-size: 14px; background: #fffefa; border: 1px solid #b3bdb0; border-radius: 4px; color: #213632; padding: 10px 12px; min-width: 0; max-width: 100%; min-height: 42px; }
select { width: 100%; }
button { font: inherit; font-size: 13px; font-weight: 600; color: #fffefa; background: #294d41; padding: 11px 17px; border: 1px solid #294d41; border-radius: 4px; cursor: pointer; min-height: 42px; }
button:hover { background: #3f6150; }
button.secondary { background: transparent; color: #294d41; }
button:disabled { opacity: .5; cursor: default; }
.notice { border-left: 3px solid #8d784f; padding: 10px 14px; background: #f6f1e4; font-size: 13px; }
.notice.real { border-color: #9b5d2d; }
.notice strong { margin-right: 6px; }
.section-nav { display: flex; flex-wrap: wrap; gap: 30px; padding: 25px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
.section-nav a { text-decoration: none; }
.panel { padding: 38px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 20px; }
.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 24px; }
.filters { display: grid; grid-template-columns: 2fr repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
.table-scroll { overflow: auto; max-height: 440px; border: 1px solid var(--line); }
table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; }
th { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: #e9ece3; position: sticky; top: 0; z-index: 1; white-space: nowrap; }
th, td { padding: 14px 16px; border-bottom: 1px solid var(--line); }
tbody tr:last-child td { border: 0; }
td strong { font-weight: 600; }
.cell-note { display: block; font-size: 11px; color: var(--muted); margin-top: 5px; }
.tag { display: inline-block; border: 1px solid var(--line); padding: 4px 7px; font-size: 11px; white-space: nowrap; border-radius: 3px; background: #eeefe7; }
details { margin-top: 22px; }
summary { cursor: pointer; font-size: 14px; padding: 8px 0; }
.capacity-controls { display: flex; align-items: end; gap: 22px; margin-top: 15px; }
.capacity-controls label { min-width: 145px; }
.warning { color: #914518; font-weight: 600; }
.actions { display: flex; gap: 10px; }
.roster-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 25px; }
fieldset { margin: 0; padding: 22px; border: 1px solid var(--line); background: #fdfcf7; min-width: 0; }
legend { font: 23px Georgia, serif; padding: 0 8px; }
fieldset label + label { margin-top: 15px; }
#roster-status { font-size: 13px; }
.formula { font: 23px Georgia, serif; line-height: 1.6; }
.rating-key { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; }
.rating-key strong { margin-left: 6px; }
.replay-controls { display: grid; grid-template-columns: 1fr 1.5fr auto; align-items: start; gap: 24px; margin-top: 25px; }
.replay-controls button { margin-top: 21px; }
input[type=range] { width: 100%; accent-color: #294d41; padding: 0; border: 0; background: transparent; }
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); margin: 25px 0; }
.metrics > div { background: #e9ece3; padding: 22px; display: flex; flex-direction: column; gap: 10px; }
.metrics span { font-size: 12px; }
.metrics strong { font: 42px Georgia, serif; font-variant-numeric: tabular-nums; }
.metrics small { line-height: 1.5; }
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 20px 0; font-size: 12px; }
.comparison i { display: block; height: 8px; min-width: 2px; background: #8a987b; margin-top: 10px; transition: width .3s; }
.comparison > div:last-child i { background: #294d41; }
footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 30px; font-size: 11px; color: var(--muted); }
.loading { padding: 60px 0; }
@media (max-width: 800px) { .filters { grid-template-columns: 1fr 1fr; } .search-label { grid-column: 1 / -1; } .roster-grid { grid-template-columns: 1fr; } .replay-controls { grid-template-columns: 1fr; gap: 16px; } .replay-controls button { margin: 0; } .section-heading { align-items: start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } }
@media (max-width: 500px) { .site-header, main, footer { width: calc(100% - 32px); } .edition { max-width: 90px; text-align: right; line-height: 1.6; } .wordmark { gap: 9px; font-size: 14px; } .workspace { padding: 16px; } .section-nav { gap: 15px; font-size: 12px; } .capacity-controls { align-items: stretch; flex-direction: column; gap: 0; } footer { flex-direction: column; gap: 8px; } }
@media (prefers-reduced-motion: reduce) { .comparison i { transition: none; } }
+172
View File
@@ -0,0 +1,172 @@
import './lab.css';
type Source = { name: string; url: string; retrieved_at: string };
type Race = { id: string; office: string; state: string; name: string; round: string };
type Candidate = { id: string; race_id: string; name: string; affiliation: string; ballot_lines: string[]; source: Source };
type Document = { id: string; kind: string; mode: 'demo' | 'real'; label: string; as_of: string; source: Source; catalog_id?: string };
type Catalog = Document & { complete: boolean; races: Race[]; candidates: Candidate[] };
type Ratings = Document & { entries: { race_id: string; rating: string }[] };
type Slot = { id: string; office: string; affiliation: string };
type Rules = { slots: Slot[]; formula: string; rounding: string; multipliers: Record<string, number> };
type Capacity = { offices: { office: string; requirements: { group: string; available: number; required: number; shortfall: number }[] }[]; sufficient_candidates: boolean; catalog_complete: boolean };
type Score = { slot: string; candidate_id: string; points: number | null; reason?: string; vote_share?: number; win_bonus?: number; multiplier?: number; baseline_points?: number; leader_preview_points?: number | null; status?: string };
type Preview = { valid: boolean; complete_roster: boolean; errors: string[]; scores: Score[]; missing_scores?: number; unavailable_leader_previews?: number; known_subtotal?: number; baseline_subtotal?: number; leader_preview_subtotal?: number; total: number | null };
const root = document.querySelector<HTMLElement>('#app')!;
let documents: Document[] = [];
let rules: Rules;
let catalog: Catalog;
let ratingId = '';
let roster: Record<string, string> = {};
let step = 0;
let playing: ReturnType<typeof setInterval> | undefined;
let previewVersion = 0;
let capacityVersion = 0;
const labels: Record<string, string> = { house: 'House', senate: 'Senate', governor: 'Governor', democrat: 'Democrat', republican: 'Republican', other: 'Other', flex: 'Flex', safe: 'Safe', likely: 'Likely', lean: 'Lean', toss_up: 'Toss-up', total: 'All candidates' };
const escape = (value: unknown) => String(value).replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!);
const label = (value: string) => labels[value] ?? value;
const money = (value: number | null | undefined) => value == null ? '—' : value.toFixed(2);
const element = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const ratings = () => documents.filter(document => document.kind === 'ratings' && document.catalog_id === catalog.id) as Ratings[];
const results = () => documents.filter(document => document.kind === 'results' && document.catalog_id === catalog.id);
const selectedRatings = () => ratings().find(rating => rating.id === ratingId);
const raceFor = (candidate: Candidate) => catalog.races.find(race => race.id === candidate.race_id)!;
const fits = (candidate: Candidate, slot: Slot) => raceFor(candidate).office === slot.office && (slot.affiliation === 'flex' || candidate.affiliation === slot.affiliation);
const options = (items: { id: string; name: string }[], selected = '') => items.map(item => `<option value="${escape(item.id)}" ${item.id === selected ? 'selected' : ''}>${escape(item.name)}</option>`).join('');
function sourceLink(source: Source): string {
if (!/^https?:\/\//.test(source.url)) return '<span class="muted">Fictional example</span>';
return `<a href="${escape(source.url)}" target="_blank" rel="noopener noreferrer">Source ↗</a> <span class="muted">${escape(source.retrieved_at.slice(0, 10))}</span>`;
}
async function api<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`/api/${path}`, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const envelope = await response.json();
if (!response.ok || !envelope.ok) throw new Error(envelope.message ?? `Request failed (${response.status})`);
return envelope.payload as T;
}
function stopReplay() {
clearInterval(playing);
playing = undefined;
const button = document.getElementById('play');
if (button) button.textContent = 'Play replay';
}
function render() {
stopReplay();
++previewVersion;
++capacityVersion;
const snapshots = results();
root.innerHTML = `
<section class="intro"><p class="eyebrow">The draft starts with a little homework.</p><h1>Build your party.<br><em>Make every race count.</em></h1><p class="description">Try your 12 picks, explore the field, and replay a fictional election night. A game among friends, taking shape for 2026.</p></section>
<section class="workspace" aria-label="Data selection"><label>Candidate catalog<select id="dataset">${options(documents.filter(item => item.kind === 'catalog').map(item => ({ id: item.id, name: item.label })), catalog.id)}</select></label><p class="notice ${catalog.mode}"><strong>${catalog.mode === 'demo' ? 'SYNTHETIC DEMO' : catalog.complete ? 'REAL CATALOG' : 'PARTIAL REAL CATALOG'}</strong> ${catalog.mode === 'demo' ? 'Every candidate, rating, vote and call in this dataset is fictional.' : catalog.complete ? 'Candidate coverage is marked complete by its curator. Ratings and live coverage still need verification before a draft.' : 'Candidate coverage is incomplete. Verify the remaining candidates, real ratings and live results before a draft.'}</p><p class="small">Catalog dated ${escape(catalog.as_of)} · ${sourceLink(catalog.source)}</p></section>
<nav class="section-nav" aria-label="Prototype sections"><a href="#field">01 / The field</a><a href="#party">02 / Your party</a><a href="#night">03 / Election night</a></nav>
<section id="field" class="panel"><div class="section-heading"><div><p class="eyebrow">01 / Scout your picks</p><h2>The field</h2></div><span id="candidate-count" class="small"></span></div>
<div class="filters"><label class="search-label">Search candidates<input id="search" type="search" placeholder="Name or race"></label>${['office', 'affiliation', 'state', 'rating'].map(filter => `<label>${filter === 'affiliation' ? 'Party' : label(filter)}<select id="filter-${filter}"><option value="">All ${filter === 'affiliation' ? 'parties' : filter + 's'}</option>${filter === 'state' ? options([...new Set(catalog.races.map(race => race.state))].sort().map(state => ({ id: state, name: state }))) : options((filter === 'office' ? ['house', 'senate', 'governor'] : filter === 'affiliation' ? ['democrat', 'republican', 'other'] : ['safe', 'likely', 'lean', 'toss_up', 'unrated']).map(id => ({ id, name: label(id) })))}</select></label>`).join('')}</div>
<div class="table-scroll"><table><thead><tr><th>Candidate</th><th>Race</th><th>Party / ballot lines</th><th>Frozen rating</th><th>Provenance</th></tr></thead><tbody id="candidates"></tbody></table></div>
<details><summary>Can everyone fill a roster?</summary><div class="capacity-controls"><label>League size<select id="players">${options(Array.from({ length: 11 }, (_, index) => ({ id: String(index + 2), name: `${index + 2} players` })), '12')}</select></label><p class="small">Each office needs one D, one R, one Other and one Flex per player. Counts include all race ratings.</p></div><div id="capacity" aria-live="polite"></div></details>
</section>
<section id="party" class="panel"><div class="section-heading"><div><p class="eyebrow">02 / Twelve picks. Three offices.</p><h2>Your party</h2></div><div class="actions">${catalog.mode === 'demo' ? '<button id="fill">Try an example roster</button>' : ''}<button id="clear" class="secondary">Clear picks</button></div></div><p class="small">One Democrat, one Republican, one Other and one Flex in each office. Other includes independent and third-party candidates. Flex accepts any party in that office. These picks stay in this page only.</p><div class="roster-grid">${['house', 'senate', 'governor'].map(office => `<fieldset><legend>${label(office)}</legend>${rules.slots.filter(slot => slot.office === office).map(slot => `<label>${label(slot.affiliation)}<select data-slot="${escape(slot.id)}" aria-label="${label(office)} ${label(slot.affiliation)}"></select></label>`).join('')}</fieldset>`).join('')}</div><p id="roster-status" role="status"></p></section>
<section id="night" class="panel"><div class="section-heading"><div><p class="eyebrow">03 / Follow the swings</p><h2>Election night, on replay.</h2></div><span class="tag">${catalog.mode === 'demo' ? 'Fictional results' : 'No verified live feed'}</span></div><p class="formula">${escape(rules.formula)}</p><div class="rating-key">${Object.entries(rules.multipliers).map(([rating, factor]) => `<span>${label(rating)} <strong>×${factor.toFixed(2)}</strong></span>`).join('')}</div><p class="small">${escape(rules.rounding)} Ratings are selected as a single snapshot here; a real league will freeze one before the draft. A lead is not a win. Corrections can reverse awarded points.</p><div class="replay-controls"><label>Rating snapshot<select id="rating-snapshot" ${ratings().length ? '' : 'disabled'}>${ratings().length ? options(ratings().map(item => ({ id: item.id, name: item.label })), ratingId) : '<option>No ratings imported</option>'}</select></label><label class="timeline">Result snapshot<input id="timeline" type="range" min="0" max="${Math.max(0, snapshots.length - 1)}" value="${step}" ${snapshots.length ? '' : 'disabled'}><span id="snapshot-label" class="small"></span></label><button id="play" ${snapshots.length > 1 ? '' : 'disabled'}>Play replay</button></div><p id="snapshot-source" class="small"></p><div id="preview" aria-live="polite"></div></section>`;
element<HTMLSelectElement>('dataset').onchange = event => {
catalog = documents.find(document => document.id === (event.target as HTMLSelectElement).value) as Catalog;
roster = {};
ratingId = ratings()[0]?.id ?? '';
step = 0;
render();
};
for (const id of ['search', 'filter-office', 'filter-affiliation', 'filter-state', 'filter-rating']) element(id).oninput = renderCandidates;
element('players').onchange = () => void renderCapacity();
element('clear').onclick = () => { roster = {}; renderRoster(); void renderPreview(); };
const fill = document.getElementById('fill');
if (fill) fill.onclick = () => {
roster = {};
for (const slot of rules.slots) {
const eligible = catalog.candidates.filter(candidate => fits(candidate, slot) && !Object.values(roster).includes(candidate.id));
const preferredRatings: Record<string, string> = { democrat: 'toss_up', republican: 'safe', other: 'likely', flex: 'lean' };
const preferredRating = preferredRatings[slot.affiliation];
const match = eligible.find(candidate => selectedRatings()?.entries.some(entry => entry.race_id === candidate.race_id && entry.rating === preferredRating)) ?? eligible[0];
if (match) roster[slot.id] = match.id;
}
renderRoster();
void renderPreview();
};
element('rating-snapshot').onchange = event => { ratingId = (event.target as HTMLSelectElement).value; renderCandidates(); void renderPreview(); };
element('timeline').oninput = event => { stopReplay(); step = Number((event.target as HTMLInputElement).value); void renderPreview(); };
element('play').onclick = () => {
if (playing) { stopReplay(); return; }
if (step === snapshots.length - 1) step = 0;
element('play').textContent = 'Pause replay';
void renderPreview();
playing = setInterval(() => {
step += 1;
void renderPreview();
if (step === snapshots.length - 1) stopReplay();
}, 1800);
};
renderCandidates();
renderRoster();
void renderCapacity();
void renderPreview();
}
function renderCandidates() {
const search = element<HTMLInputElement>('search').value.toLowerCase();
const filters = ['office', 'affiliation', 'state', 'rating'].map(filter => element<HTMLSelectElement>(`filter-${filter}`).value);
const ratingEntries = selectedRatings()?.entries ?? [];
const candidates = catalog.candidates.filter(candidate => {
const race = raceFor(candidate);
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating ?? 'unrated';
return `${candidate.name} ${race.name}`.toLowerCase().includes(search) && [race.office, candidate.affiliation, race.state, rating].every((value, index) => !filters[index] || filters[index] === value);
});
element('candidate-count').textContent = `${candidates.length} of ${catalog.candidates.length} candidates`;
element('candidates').innerHTML = candidates.map(candidate => {
const race = raceFor(candidate);
const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating;
return `<tr><td><strong>${escape(candidate.name)}</strong></td><td>${escape(race.name)}<span class="cell-note">${escape(race.state)} · ${escape(label(race.office))}</span></td><td>${escape(label(candidate.affiliation))}<span class="cell-note">${escape(candidate.ballot_lines.join(' / '))}</span></td><td><span class="tag">${rating ? `${escape(label(rating))} ×${rules.multipliers[rating].toFixed(2)}` : 'Missing rating'}</span></td><td class="small">${sourceLink(candidate.source)}</td></tr>`;
}).join('') || '<tr><td colspan="5">No candidates match these filters.</td></tr>';
}
function renderRoster() {
for (const slot of rules.slots) {
const select = root.querySelector<HTMLSelectElement>(`[data-slot="${slot.id}"]`)!;
select.innerHTML = '<option value="">Choose a candidate</option>' + catalog.candidates.filter(candidate => fits(candidate, slot)).map(candidate => `<option value="${escape(candidate.id)}" ${roster[slot.id] === candidate.id ? 'selected' : ''} ${Object.entries(roster).some(([id, pick]) => id !== slot.id && pick === candidate.id) ? 'disabled' : ''}>${escape(candidate.name)} · ${escape(raceFor(candidate).state)}</option>`).join('');
select.onchange = () => { roster[slot.id] = select.value; renderRoster(); void renderPreview(); };
}
const filled = Object.values(roster).filter(Boolean).length;
element('roster-status').textContent = `${filled} / 12 slots filled${filled === 12 ? ' · Ready to simulate.' : ' · Fill each slot to calculate a complete total.'}`;
}
async function renderCapacity() {
const version = ++capacityVersion;
try {
const result = await api<Capacity>('capacity', { catalog_id: catalog.id, players: Number(element<HTMLSelectElement>('players').value) });
if (version !== capacityVersion) return;
element('capacity').innerHTML = `<p class="notice"><strong>${result.sufficient_candidates ? 'Enough candidates in this catalog.' : 'Not enough candidates in this catalog.'}</strong> ${!result.catalog_complete ? 'This is a partial catalog; these counts do not establish nationwide feasibility.' : catalog.mode === 'demo' ? 'Synthetic capacity only; this does not establish real-world feasibility.' : 'Ratings and results coverage still require separate verification.'}</p><div class="table-scroll"><table><thead><tr><th>Office</th><th>Group</th><th>Available</th><th>Needed</th><th>Shortfall</th></tr></thead><tbody>${result.offices.flatMap(office => office.requirements.map(group => `<tr><td>${label(office.office)}</td><td>${label(group.group)}</td><td>${group.available}</td><td>${group.required}</td><td class="${group.shortfall ? 'warning' : ''}">${group.shortfall}</td></tr>`)).join('')}</tbody></table></div>`;
} catch (error) { if (version === capacityVersion) element('capacity').textContent = String(error); }
}
async function renderPreview() {
const version = ++previewVersion;
const snapshot = results()[step];
element<HTMLInputElement>('timeline').value = String(step);
element('snapshot-label').textContent = snapshot ? `${snapshot.label} · ${snapshot.as_of}` : 'No result snapshots imported';
element('snapshot-source').innerHTML = snapshot ? `Results: ${sourceLink(snapshot.source)}${selectedRatings() ? ` · Ratings: ${sourceLink(selectedRatings()!.source)}` : ''}` : '';
if (!snapshot || !ratingId) { element('preview').innerHTML = '<p class="notice">Scoring is unavailable until both ratings and results are imported for this catalog. Missing data is not treated as zero.</p>'; return; }
element('preview').innerHTML = '<p role="status">Calculating…</p>';
try {
const preview = await api<Preview>('score-preview', { catalog_id: catalog.id, ratings_id: ratingId, results_id: snapshot.id, roster: Object.entries(roster).filter(([, id]) => id).map(([slot, candidate_id]) => ({ slot, candidate_id })) });
if (version !== previewVersion) return;
if (!preview.valid) { element('preview').innerHTML = `<p class="notice warning">${preview.errors.map(escape).join('<br>')}</p>`; return; }
const max = Math.max(preview.known_subtotal ?? 0, preview.baseline_subtotal ?? 0, 1);
element('preview').innerHTML = `<div class="metrics"><div><span>Complete roster total</span><strong>${money(preview.total)}</strong><small>${preview.total === null ? 'Needs 12 picks with available scores' : 'Counted shares + awarded bonuses'}</small></div><div><span>Known subtotal</span><strong>${money(preview.known_subtotal)}</strong><small>${preview.missing_scores ?? 0} selected scores unavailable</small></div><div><span>If current leaders win</span><strong>${money(preview.leader_preview_subtotal)}</strong><small>${preview.unavailable_leader_previews ?? 0} previews unavailable · not awarded points</small></div></div><div class="comparison" aria-label="Scoring comparison"><div><span>No multipliers ${money(preview.baseline_subtotal)}</span><i style="width:${(preview.baseline_subtotal ?? 0) / max * 100}%"></i></div><div><span>Race-weighted ${money(preview.known_subtotal)}</span><i style="width:${(preview.known_subtotal ?? 0) / max * 100}%"></i></div></div><div class="table-scroll"><table><thead><tr><th>Pick</th><th>Vote share</th><th>Win bonus</th><th>Multiplier</th><th>Points</th><th>If leader wins</th></tr></thead><tbody>${preview.scores.map(score => `<tr><td><strong>${escape(catalog.candidates.find(candidate => candidate.id === score.candidate_id)?.name)}</strong><span class="cell-note">${escape(score.slot.split(':').map(label).join(' / '))} · ${escape(score.reason ?? score.status)}</span></td><td>${score.vote_share === undefined ? '—' : `${score.vote_share.toFixed(2)}%`}</td><td>${score.win_bonus ?? '—'}</td><td>${score.multiplier === undefined ? '—' : `×${score.multiplier.toFixed(2)}`}</td><td><strong>${money(score.points)}</strong></td><td>${money(score.leader_preview_points)}</td></tr>`).join('') || '<tr><td colspan="6">Choose candidates above to see their points.</td></tr>'}</tbody></table></div>`;
} catch (error) { if (version === previewVersion) element('preview').textContent = String(error); }
}
async function start() {
try {
const [ruleData, catalogData] = await Promise.all([api<Rules>('rules'), api<{ documents: Document[] }>('catalog')]);
rules = ruleData;
documents = catalogData.documents;
catalog = documents.find(document => document.kind === 'catalog' && document.mode === 'demo') as Catalog ?? documents.find(document => document.kind === 'catalog') as Catalog;
if (!catalog) { root.innerHTML = '<section class="intro"><h1>The field is empty.</h1><p>Import a catalog with the local admin tool to start exploring. Setup instructions are in the repository README.</p></section>'; return; }
ratingId = ratings()[0]?.id ?? '';
render();
} catch (error) {
root.innerHTML = `<section class="intro"><h1>Unable to load the league.</h1><p role="alert">${escape(error)}</p><p>Check that the C++ backend is running, then try again.</p><button id="retry">Retry</button></section>`;
element('retry').onclick = () => void start();
}
}
void start();
+55
View File
@@ -0,0 +1,55 @@
import { api, query, user } from './api';
import { click, element, escape, field, go, submit } from './ui';
import type { Catalogs, League, LeagueView } from './types';
export async function leaguesPage(signal: AbortSignal) {
const leagues = await api<League[]>('leagues', undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<div class="page-heading"><div><p class="eyebrow">2026 midterms</p><h1>My leagues</h1></div><div class="actions"><a class="button secondary" href="#join">Join a league</a><a class="button" href="#create">Create a league</a></div></div>${leagues.length ? `<div class="league-grid">${leagues.map(l => `<a class="panel league-card" href="#league/${l.id}"><span class="badge">${l.catalog_mode === 'demo' ? 'Practice · ' : ''}${escape(l.status)}</span><h2>${escape(l.name)}</h2><p>${escape(l.team_name)}</p><span class="muted">${l.members} / ${l.max_teams} teams</span><span class="arrow" aria-hidden="true">↗</span></a>`).join('')}</div>` : '<section class="empty"><h2>Start with your people.</h2><p>Create a private league, then invite your friends. You can get everyone together while election data is being prepared.</p></section>'}`;
}
export async function createPage(signal: AbortSignal) {
const catalogs = await api<Catalogs>('catalogs', undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><h1>Create a league</h1><section class="panel narrow"><form id="create">${field('League name', 'name', '', 'maxlength="80"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}<label>Maximum teams<select name="max_teams">${Array.from({ length: 11 }, (_, i) => `<option ${i === 6 ? 'selected' : ''}>${i + 2}</option>`).join('')}</select></label>${catalogs.allow_demo ? '<label>Candidate pool<select name="catalog_id"><option value="">Latest real candidates</option>' + catalogs.catalogs.filter(c => c.mode === 'demo').map(c => `<option value="${escape(c.id)}">Practice · ${escape(c.label)}</option>`).join('') + '</select></label>' : ''}<p class="muted">Private by default. Twelve picks per team. The draft opens when the candidate pool, ratings, and result sources are ready.</p><button type="submit">Create league</button></form></section>`;
submit('#create', async (data) => { const result = await api<{
league_id: string;
}>('league/create', { name: data.get('name'), team_name: data.get('team_name'), max_teams: Number(data.get('max_teams')), catalog_id: data.get('catalog_id') || null }); go(`league/${result.league_id}`); });
}
export function joinPage(invite = '') {
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><h1>Join a league</h1><section class="panel narrow"><form id="join">${field('Invitation link or code', 'invite', invite, 'autocomplete="off"')}${field('Your team name', 'team_name', '', 'maxlength="60"')}<button type="submit">Join league</button></form></section>`;
submit('#join', async (data) => { const code = String(data.get('invite')).split('#join/').pop()!.trim(); const result = await api<{
league_id: string;
}>('league/join', { invite: code, team_name: data.get('team_name') }); go(`league/${result.league_id}`); });
}
export async function leaguePage(id: string, signal: AbortSignal) {
const view = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
const l = view.league, commissioner = l.commissioner_id === user?.id;
element('#page').innerHTML = `<a href="#leagues" class="back">← My leagues</a><div class="page-heading"><div><p class="eyebrow">${l.catalog_mode === 'demo' ? 'Practice · fictional candidates · ' : ''}${escape(l.status)}</p><h1>${escape(l.name)}</h1></div><a class="button" href="#draft/${id}">${l.status === 'forming' ? 'Draft room' : l.status === 'drafted' ? 'View draft' : 'Enter draft'}</a></div><nav class="subnav"><a href="#roster/${id}">My roster</a><a href="#standings/${id}">Standings</a><a href="#results/${id}">Race results</a></nav>${l.status === 'forming' && !view.readiness.ready ? `<aside class="notice"><strong>Getting ready to draft</strong><ul>${view.readiness.problems.map(p => `<li>${escape(p)}</li>`).join('')}</ul></aside>` : ''}<section class="panel"><div class="page-heading"><h2>Teams <span class="muted">${view.teams.length} / ${l.max_teams}</span></h2>${commissioner && l.status === 'forming' ? '<button id="invite">Invite friends</button>' : ''}</div><div id="invite-link"></div><ul class="team-list">${view.teams.map(t => `<li><span><strong>${escape(t.name)}</strong><small>${escape(t.username)}${t.user_id === l.commissioner_id ? ' · commissioner' : ''}</small></span>${commissioner && t.user_id !== user?.id && l.status === 'forming' ? `<button class="quiet remove" data-team="${t.id}">Remove</button>` : ''}</li>`).join('')}</ul></section><details class="panel"><summary>Team settings</summary><form id="rename">${field('Team name', 'name', view.my_team.name, 'maxlength="60"')}<button type="submit">Save team name</button></form></details>${commissioner ? `<details class="panel"><summary>League name</summary><form id="league-name">${field('League name','name',l.name,'maxlength="80"')}<button type="submit">Save league name</button></form></details>` : ''}${commissioner && l.status === 'forming' ? '<details class="panel" id="commissioner"><summary>Commissioner settings</summary><div id="settings">Loading settings…</div><form id="ratings-import"><label>Import sourced ratings (JSON)<input name="ratings" type="file" accept=".json,application/json" required></label><p class="muted">Use the ratings schema in the data contract. Include a source URL, retrieval date, and a new snapshot ID.</p><button type="submit">Import ratings</button></form></details>' : ''}`;
const reload = () => leaguePage(id, signal);
if (commissioner) submit('#league-name', async data => { await api('league/name', {league_id:id, name:data.get('name')}); await reload(); });
submit('#rename', async (data) => { await api('team/name', { league_id: id, name: data.get('name') }); await reload(); });
click('#invite', async () => { const result = await api<{
invite: string;
}>('league/invite', { league_id: id }); const url = `${location.origin}${location.pathname}#join/${result.invite}`; element('#invite-link').innerHTML = `<label>Share this private link · expires in 7 days<input readonly value="${escape(url)}" aria-label="Invitation link"></label><p class="muted">Creating another invitation replaces this one.</p>`; });
document.querySelectorAll<HTMLButtonElement>('.remove').forEach(button => click(`[data-team="${button.dataset.team}"]`, async () => { await api('league/remove', { league_id: id, team_id: button.dataset.team }); await reload(); }));
if (commissioner && l.status === 'forming') {
submit('#ratings-import', async (form) => { const file = form.get('ratings'); if (!(file instanceof File) || file.size > 60000)
throw new Error('Choose a ratings JSON file under 60 KB.'); const document = JSON.parse(await file.text()); await api('league/ratings', { league_id: id, document }); await reload(); });
const data = await api<Catalogs>('catalogs', undefined, signal);
if (signal.aborted)
return;
element('#settings').innerHTML = `<form id="settings-form">${field('League name', 'name', l.name, 'maxlength="80"')}<label>Candidate catalog<select name="catalog_id" required>${data.catalogs.map(c => `<option value="${escape(c.id)}" ${c.id === l.catalog_id ? 'selected' : ''}>${escape(c.label)}</option>`).join('')}</select></label><label>Race ratings<select name="ratings_id"><option value="">Awaiting sourced ratings</option>${data.ratings.map(r => `<option value="${escape(r.id)}" data-catalog="${escape(r.catalog_id)}" ${r.id === l.ratings_id ? 'selected' : ''}>${escape(r.label)}</option>`).join('')}</select></label><p class="muted">Catalog and ratings freeze when the draft starts. Changing these settings clears player queues.</p><button type="submit">Save league settings</button></form><button class="quiet" id="revoke">Revoke invitations</button>`;
const filterRatings = () => { const catalog = element<HTMLSelectElement>('[name=catalog_id]').value; const ratings = element<HTMLSelectElement>('[name=ratings_id]'); for (const option of ratings.options) {
option.hidden = !!option.dataset.catalog && option.dataset.catalog !== catalog;
if (option.selected && option.hidden)
ratings.value = '';
} };
element('[name=catalog_id]').addEventListener('change', filterRatings);
filterRatings();
submit('#settings-form', async (form) => { await api('league/settings', { league_id: id, ...Object.fromEntries(form), ratings_id: form.get('ratings_id') || null }); await reload(); });
click('#revoke', async () => { await api('league/revoke-invites', { league_id: id }); element('#invite-link').textContent = 'Invitations revoked.'; });
}
}
+56
View File
@@ -1 +1,57 @@
import './style.css';
import { api, beginPage, setUser, user } from './api';
import type { User } from './api';
import { authPage, accountPage } from './auth';
import { createPage, joinPage, leaguePage, leaguesPage } from './leagues';
import { candidatesPage } from './candidates';
import { draftPage } from './draft';
import { resultsPage, standingsPage } from './results';
import { adminPage } from './admin';
import { click, element, escape, message } from './ui';
function shell(active: string) { element('#app').innerHTML = `<header class="site-header"><a class="wordmark" href="#leagues"><span class="monogram">fp.</span><span>Fantasy Politics</span></a>${user ? `<nav aria-label="Main navigation">${['leagues', 'candidates', 'results'].map(p => `<a href="#${p}" ${active === p ? 'aria-current="page"' : ''}>${p[0].toUpperCase() + p.slice(1)}</a>`).join('')}</nav><details class="account-menu"><summary>${escape(user.username)}</summary><div><a href="#account">Account</a>${user.admin ? '<a href="#admin">Source diagnostics</a>' : ''}<button class="quiet" id="logout">Sign out</button></div></details>` : '<span class="edition">The 2026 edition</span>'}</header><main><div id="notice" role="alert" class="notice error" hidden></div><div id="page"><p class="loading" role="status">Loading…</p></div></main><footer><span>A game among friends.</span><span>2026 US midterms</span></footer>`; click('#logout', async () => { await api('auth/logout', {}); setUser(null); await render(); }); }
async function render() {
const signal = beginPage();
const path = location.hash.slice(1).split('/');
const page = path[0] || 'leagues';
shell(page);
try {
if (!user || page === 'reset') {
authPage(path, () => void render());
return;
}
if (page === 'leagues')
await leaguesPage(signal);
else if (page === 'create')
await createPage(signal);
else if (page === 'join')
joinPage(path[1]);
else if (page === 'league' && path[1])
await leaguePage(path[1], signal);
else if (page === 'draft' && path[1])
await draftPage(path[1], signal);
else if (page === 'candidates')
candidatesPage(signal, path[1]);
else if (page === 'results')
await resultsPage(signal, path[1]);
else if ((page === 'standings' || page === 'roster') && path[1])
await standingsPage(path[1], signal, page === 'roster');
else if (page === 'account')
accountPage(() => void render());
else if (page === 'admin')
await adminPage(signal);
else
element('#page').innerHTML = '<h1>Page not found</h1><a href="#leagues">Back to my leagues</a>';
}
catch (error) {
if (!signal.aborted)
message(error);
}
}
window.addEventListener('hashchange', () => void render());
async function start() { try {
setUser(await api<User>('auth/me'));
}
catch {
setUser(null);
} await render(); }
void start();
+56
View File
@@ -0,0 +1,56 @@
import { api, query, user } from './api';
import { element, escape, message, points, sourceLink, time } from './ui';
import type { LeagueView, Provenance, RaceResult, Standing } from './types';
function provenance(source: Provenance) { return `${sourceLink(source.url, source.source_name || source.source_id)}${source.stale ? ' · stale, showing last good data' : ''}<br>Fetched ${time(source.received_at)} · source updated ${escape(source.source_time || 'not supplied')} · checked ${time(source.last_checked)}`; }
export async function resultsPage(signal: AbortSignal, league?: string) {
element('#page').innerHTML = `${league ? `<a class="back" href="#league/${league}">← League</a>` : ''}<p class="eyebrow">Election night</p><h1>Race results</h1><p class="muted">Updates every 15 seconds. Missing data stays unknown. Race calls can change.</p><label class="search-label">Find a race<input id="race-search" type="search" placeholder="State, district, or office"></label><div id="race-results"></div>`;
let races: RaceResult[] = [], timer: number | undefined;
function draw() { const search = element<HTMLInputElement>('#race-search').value.toLowerCase(); const filtered = races.filter(r => (r.name + ' ' + r.state + ' ' + r.office).toLowerCase().includes(search)); element('#race-results').innerHTML = filtered.map(r => `<details class="panel race"><summary><span>${escape(r.name)}</span><span class="muted">${r.counts ? r.counts.total_votes.toLocaleString() + ' votes' : 'Awaiting counts'} · ${r.outcome ? escape(r.outcome.status.replace('_', ' ')) : 'No call'}</span></summary>${r.counts ? `<p>${provenance(r.counts.provenance)}</p><p>November first-round counts</p><table><thead><tr><th>Candidate</th><th>Votes</th><th>Share</th></tr></thead><tbody>${r.candidates.map(c => { const votes = r.counts!.votes.find(v => v.candidate_id === c.id)?.votes; return `<tr><td>${escape(c.name)}<small>${escape(c.affiliation)}</small></td><td>${votes == null ? '—' : votes.toLocaleString()}</td><td>${votes == null || !r.counts!.total_votes ? '—' : (votes * 100 / r.counts!.total_votes).toFixed(2) + '%'}</td></tr>`; }).join('')}</tbody></table>` : '<p>No verified vote counts have arrived.</p>'}${r.outcome ? `<p>${provenance(r.outcome.provenance)}</p><p>Outcome round: ${escape(r.outcome.round)}${r.outcome.winner_id ? ' · Winner: ' + escape(r.candidates.find(c => c.id === r.outcome!.winner_id)?.name || r.outcome.winner_id) : ''}</p>` : '<p>No verified outcome has arrived.</p>'}</details>`).join('') || '<section class="empty"><h2>Waiting for the field.</h2><p>Results will appear here as verified sources become available.</p></section>'; }
async function update() { try {
const result = await api<{
races: RaceResult[];
}>(query('results', { league_id: league }), undefined, signal);
if (signal.aborted)
return;
const changed = JSON.stringify(result.races) !== JSON.stringify(races);
races = result.races;
if (changed || !element('#race-results').innerHTML)
draw();
}
catch (error) {
message(error);
} if (!signal.aborted)
timer = window.setTimeout(() => void update(), 15000); }
element('#race-search').addEventListener('input', draw);
signal.addEventListener('abort', () => clearTimeout(timer));
await update();
}
export async function standingsPage(id: string, signal: AbortSignal, rosterOnly = false) {
const view = await api<LeagueView>(query('league', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
element('#page').innerHTML = `<a class="back" href="#league/${id}">← ${escape(view.league.name)}</a><h1>${rosterOnly ? 'My roster' : 'Standings'}</h1><nav class="subnav"><a href="#roster/${id}">My roster</a><a href="#standings/${id}">Standings</a><a href="#results/${id}">Race results</a></nav><div id="scores"></div><details class="panel"><summary>How points work</summary><p>(November first-round vote share + 50 for the eventual called or certified winner) × race rating.</p><p>Safe ×1 · Likely ×1.1 · Lean ×1.25 · Toss-up ×1.5. Each candidate is rounded to two decimals before adding. A later runoff changes the winner bonus, while November vote share stays in place.</p><p>Provisional positions compare known subtotals. Check reporting coverage before comparing teams with missing scores.</p></details>`;
let timer: number | undefined, previous = '';
function roster(row: Standing) { return `<div class="roster">${row.roster.map(score => `<details class="candidate"><summary><span><strong>${escape(score.candidate.name)}</strong><small>${escape(score.slot)}</small></span><strong>${points(score.points)} pts</strong></summary><p>November share ${score.share == null ? 'unknown' : score.share.toFixed(2) + '%'} · win bonus ${score.win_bonus} · rating ${score.rating == null ? 'unknown' : '×' + score.rating}</p>${score.counts ? `<p>${provenance(score.counts.provenance)}</p>` : '<p>Awaiting counts.</p>'}${score.outcome ? `<p>${escape(score.outcome.status)} · ${provenance(score.outcome.provenance)}</p>` : ''}</details>`).join('') || '<p class="empty">Your roster starts with the first pick.</p>'}</div>`; }
async function update() { try {
const data = await api<{
teams: Standing[];
provisional: boolean;
}>(query('standings', { league_id: id }), undefined, signal);
if (signal.aborted)
return;
const serialized = JSON.stringify(data);
if (serialized !== previous) {
previous = serialized;
const mine = view.teams.find(t => t.user_id === user?.id)?.id;
const rows = rosterOnly ? data.teams.filter(r => r.team.id === mine) : data.teams;
element('#scores').innerHTML = `${data.provisional ? '<p class="notice">Provisional · totals include only reported scores.</p>' : ''}${rows.map(row => rosterOnly ? roster(row) : `<details class="panel standing"><summary><span class="rank">${row.rank ?? '—'}</span><span class="grow"><strong>${escape(row.team.name)}</strong><small>${row.known_scores} / 12 scores available</small></span><strong>${points(row.known_subtotal)} <small>${row.total == null ? 'known subtotal' : 'points'}</small></strong></summary>${roster(row)}</details>`).join('')}`;
}
}
catch (error) {
message(error);
} if (!signal.aborted)
timer = window.setTimeout(() => void update(), 15000); }
signal.addEventListener('abort', () => clearTimeout(timer));
await update();
}
+685 -52
View File
@@ -1,58 +1,691 @@
:root {
font-family: 'Avenir Next', Avenir, 'Segoe UI', sans-serif;
color: #213632;
background: #f6f4ee;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
--muted: #596760;
--line: #d6dbd2;
font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
color:#233b31;
background:#f6f4ee;
font-synthesis:none;
--green:#254f3b;
--line:#d9ded4;
--muted:#66736a;
--paper:#fffefa;
}
* { box-sizing: border-box; }
body { margin: 0; }
a { color: inherit; }
a:focus-visible { outline: 3px solid #9a5a21; outline-offset: 6px; }
.site-header, main, footer { width: min(1120px, calc(100% - 80px)); margin-inline: auto; }
.site-header { min-height: 112px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); }
.wordmark { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; font-size: 17px; font-weight: 650; letter-spacing: -0.03em; }
.monogram { display: grid; place-items: center; width: 43px; height: 43px; border: 1px solid #213632; border-radius: 50%; font-family: Georgia, serif; font-size: 25px; font-weight: 400; padding-bottom: 4px; }
.edition { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.13em; }
.intro { padding: 75px 0 70px; }
.eyebrow { margin: 0 0 28px; color: var(--muted); font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.12em; }
.eyebrow span { color: #a36324; margin-right: 9px; font-size: 16px; }
h1 { margin: 0; font-family: Georgia, 'Times New Roman', serif; font-size: clamp(48px, 6.5vw, 82px); font-weight: 400; line-height: 1.04; letter-spacing: -0.055em; }
h1 em { font-weight: 400; color: #67765e; }
.description { max-width: 520px; margin: 30px 0 26px; font-size: 17px; line-height: 1.75; color: var(--muted); }
.status { display: inline-flex; align-items: center; gap: 10px; margin: 0; padding: 12px 16px; background: #e9ece3; border-radius: 3px; font-size: 12px; line-height: 1.6; }
.status-dot { flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; background: #67765e; }
.overview { padding-top: 25px; border-top: 1px solid var(--line); }
.overview-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 20px; }
.overview-heading h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.14em; margin: 0; }
.overview-heading > span { font-size: 12px; color: var(--muted); }
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 42px; margin: 30px 0 62px; padding: 0; list-style: none; }
.step-number { color: #7c8973; font-family: Georgia, serif; font-size: 18px; }
.steps h3 { margin: 16px 0 8px; font-size: 17px; font-weight: 600; letter-spacing: -0.02em; }
.steps p { max-width: 285px; margin: 0; font-size: 14px; line-height: 1.7; color: var(--muted); }
footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 30px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); }
@media (min-width: 1000px) {
.intro { position: relative; }
.intro::after { content: '26'; position: absolute; top: 78px; right: 30px; font-family: Georgia, serif; font-size: 285px; line-height: 1; letter-spacing: -0.09em; color: #e3e7dc; z-index: -1; }
* {
box-sizing:border-box;
}
@media (max-width: 640px) {
.site-header, main, footer { width: calc(100% - 40px); }
.site-header { min-height: 88px; }
.wordmark { font-size: 15px; gap: 10px; }
.edition { max-width: 85px; text-align: right; font-size: 10px; line-height: 1.6; }
.intro { padding: 48px 0; }
.eyebrow { max-width: 290px; line-height: 1.8; }
.description { font-size: 16px; }
.steps { grid-template-columns: 1fr; gap: 28px; margin-bottom: 42px; }
.steps li { display: grid; grid-template-columns: 28px 1fr; gap: 0 15px; }
.step-number { grid-row: span 2; padding-top: 1px; }
.steps h3 { margin-top: 0; }
.steps p { max-width: none; }
footer { flex-wrap: wrap; gap: 8px 20px; }
body {
margin:0;
}
button,input,select {
font:inherit;
}
a {
color:inherit;
text-underline-offset:4px;
}
button,.button {
display:inline-flex;
align-items:center;
justify-content:center;
gap:.4rem;
background:var(--green);
color:white;
border:1px solid var(--green);
border-radius:7px;
padding:.65rem 1rem;
font-size:.9rem;
font-weight:600;
cursor:pointer;
text-decoration:none;
min-height:42px;
}
button:hover,.button:hover {
filter:brightness(1.12);
}
button:disabled {
opacity:.45;
cursor:default;
}
button.secondary,.button.secondary {
background:transparent;
color:var(--green);
border-color:#a6b5a6;
}
.quiet {
background:transparent;
color:var(--green);
border-color:transparent;
padding:.35rem .55rem;
min-height:36px;
}
input,select {
display:block;
width:100%;
border:1px solid #b9c4b9;
border-radius:6px;
background:var(--paper);
color:inherit;
padding:.7rem .8rem;
min-height:43px;
}
input:focus,select:focus,button:focus-visible,a:focus-visible,summary:focus-visible {
outline:3px solid #94bca0;
outline-offset:3px;
}
label {
display:block;
font-size:.88rem;
font-weight:550;
line-height:1.5;
}
label input,label select {
margin-top:.4rem;
}
form {
display:grid;
gap:1rem;
}
form button[type=submit] {
justify-self:start;
}
p {
line-height:1.65;
margin:.6rem 0 1rem;
}
h1,h2,h3 {
font-weight:550;
letter-spacing:-.025em;
line-height:1.2;
}
h1 {
font-family:Georgia,serif;
font-size:clamp(2.2rem,5vw,3.5rem);
font-weight:400;
margin:.25rem 0 1.6rem;
}
h2 {
font-size:1.2rem;
margin:.2rem 0 1rem;
}
small {
display:block;
font-size:.78rem;
font-weight:400;
color:var(--muted);
margin-top:.3rem;
}
.site-header {
max-width:1280px;
margin:auto;
min-height:104px;
padding:1.5rem 2rem;
display:flex;
align-items:center;
gap:2.4rem;
border-bottom:1px solid var(--line);
}
.wordmark {
display:flex;
align-items:center;
gap:.8rem;
font-size:.95rem;
text-decoration:none;
font-weight:600;
white-space:nowrap;
}
.monogram {
font-family:Georgia,serif;
font-size:2.8rem;
letter-spacing:-.07em;
font-weight:400;
}
.site-header nav {
display:flex;
gap:1.8rem;
margin:auto;
}
.site-header nav a {
text-decoration:none;
color:var(--muted);
padding:.6rem 0;
font-size:.92rem;
}
.site-header nav a[aria-current] {
border-bottom:2px solid var(--green);
color:var(--green);
}
.edition {
margin-left:auto;
font-size:.8rem;
color:var(--muted);
}
main {
max-width:1120px;
margin:3.5rem auto 5rem;
padding:0 2rem;
min-height:60vh;
}
footer {
max-width:1280px;
margin:auto;
border-top:1px solid var(--line);
padding:1.5rem 2rem;
display:flex;
justify-content:space-between;
gap:1rem;
color:var(--muted);
font-size:.78rem;
}
.page-heading {
display:flex;
align-items:center;
justify-content:space-between;
gap:1rem;
margin-bottom:1.4rem;
}
.page-heading h1,.page-heading h2 {
margin-bottom:0;
}
.eyebrow {
text-transform:uppercase;
letter-spacing:.13em;
font-size:.7rem;
font-weight:700;
color:var(--muted);
margin-bottom:.65rem;
}
.actions {
display:flex;
align-items:center;
gap:.5rem;
flex-shrink:0;
}
.panel {
background:var(--paper);
border:1px solid var(--line);
border-radius:12px;
padding:1.5rem;
margin-bottom:1.2rem;
}
.narrow {
max-width:580px;
}
.muted {
color:var(--muted);
font-size:.88rem;
}
.empty {
text-align:center;
padding:5rem 1rem;
max-width:620px;
margin:auto;
}
.empty h2 {
font-family:Georgia,serif;
font-size:1.8rem;
font-weight:400;
}
.empty p {
color:var(--muted);
}
.league-grid {
display:grid;
grid-template-columns:repeat(auto-fit,minmax(270px,1fr));
gap:1.2rem;
}
.league-card {
text-decoration:none;
position:relative;
min-height:205px;
}
.league-card h2 {
font-size:1.5rem;
margin-top:1.2rem;
}
.league-card:hover {
border-color:#7c9983;
}
.arrow {
position:absolute;
bottom:1.5rem;
right:1.5rem;
font-size:1.5rem;
}
.badge {
display:inline-block;
background:#eaf0e5;
color:#446449;
border-radius:4px;
padding:.25rem .5rem;
font-size:.7rem;
text-transform:capitalize;
}
.back {
display:inline-block;
font-size:.87rem;
color:var(--muted);
margin-bottom:1.5rem;
text-decoration:none;
}
.subnav {
display:flex;
gap:1.5rem;
margin:0 0 2rem;
font-size:.9rem;
}
.notice {
background:#eef1df;
border:1px solid #d5ddbd;
border-radius:8px;
padding:1rem 1.2rem;
margin-bottom:1.5rem;
font-size:.9rem;
}
.notice ul {
margin-bottom:0;
padding-left:1.1rem;
line-height:1.8;
}
.error {
color:#893626;
}
.notice.error {
background:#faeee6;
border-color:#dec5b2;
}
.auth {
max-width:560px;
margin:5rem auto;
}
.auth>p {
color:var(--muted);
}
.auth .panel {
margin-top:1.5rem;
}
.auth h1 {
font-size:3rem;
}
.team-list {
list-style:none;
padding:0;
margin:0;
}
.team-list li {
display:flex;
align-items:center;
justify-content:space-between;
padding:1rem 0;
border-bottom:1px solid var(--line);
}
.team-list li:last-child {
border:0;
}
details>summary {
cursor:pointer;
font-size:.88rem;
font-weight:550;
line-height:1.5;
}
details[open]>summary {
margin-bottom:1.2rem;
}
details p {
font-size:.84rem;
color:var(--muted);
overflow-wrap:anywhere;
}
.search {
margin:0 0 1.5rem;
}
.search input[type=search] {
padding:1rem;
background:var(--paper);
}
.filters {
display:flex;
gap:1rem;
flex-wrap:wrap;
margin-top:1rem;
}
.filters label {
flex:1;
min-width:100px;
}
.filters .checkbox {
display:flex;
align-items:center;
gap:.5rem;
}
.checkbox input {
width:auto;
margin:0;
}
.candidate-list {
border-top:1px solid var(--line);
}
.candidate {
padding:1.15rem 0;
border-bottom:1px solid var(--line);
}
.candidate-main {
display:flex;
align-items:center;
justify-content:space-between;
gap:1rem;
}
.candidate-meta {
display:block;
color:var(--muted);
font-size:.78rem;
margin-top:.4rem;
}
.candidate>details {
margin-top:.7rem;
}
.candidate>details>summary {
font-size:.74rem;
color:var(--muted);
}
.candidate>details[open]>summary {
margin-bottom:.4rem;
}
.pagination {
display:flex;
align-items:center;
justify-content:center;
gap:1rem;
margin-top:1.5rem;
font-size:.82rem;
}
.draft-layout {
display:grid;
grid-template-columns:minmax(0,1fr) 290px;
gap:2rem;
}
.draft-layout aside .panel {
padding:1.1rem;
}
.draft-layout aside h2 {
font-size:1rem;
}
.draft-layout ol {
padding-left:1.2rem;
font-size:.85rem;
}
.draft-layout li {
padding:.6rem 0;
}
.draft-layout #queue {
list-style:none;
padding:0;
}
.draft-layout #queue li {
display:flex;
align-items:center;
justify-content:space-between;
gap:.4rem;
}
.draft-layout #queue li>div {
white-space:nowrap;
}
.turn {
display:flex;
align-items:center;
gap:1.5rem;
background:#e9eee1;
}
.turn>div:first-child {
flex:1;
}
.turn p {
margin-bottom:0;
font-size:.85rem;
}
.clock {
font-size:2.2rem;
font-variant-numeric:tabular-nums;
}
.rank {
font-family:Georgia,serif;
font-size:1.8rem;
min-width:2rem;
}
.standing>summary,.roster summary,.race>summary {
display:flex;
align-items:center;
gap:1.2rem;
justify-content:space-between;
}
.grow {
flex:1;
}
.standing summary strong {
font-weight:600;
}
.search-label {
max-width:580px;
margin-bottom:2rem;
}
.account-menu {
position:relative;
font-size:.8rem;
}
.account-menu>div {
position:absolute;
right:0;
top:2rem;
background:var(--paper);
padding:1rem;
border:1px solid var(--line);
border-radius:8px;
z-index:5;
min-width:180px;
box-shadow:0 8px 20px #233b3110;
}
.account-menu a {
display:block;
margin-bottom:1rem;
}
.account-menu[open]>summary {
margin-bottom:0;
}
.sr-only {
position:absolute;
width:1px;
height:1px;
padding:0;
overflow:hidden;
clip:rect(0,0,0,0);
white-space:nowrap;
border:0;
}
[hidden] {
display:none!important;
}
@media(max-width:760px) {
.site-header {
padding:1rem;
gap:.8rem;
flex-wrap:wrap;
}
.site-header nav {
order:3;
width:100%;
justify-content:center;
gap:2.5rem;
}
.account-menu {
margin-left:auto;
}
.wordmark {
font-size:.85rem;
}
main {
padding:0 1rem;
margin-top:2rem;
}
.page-heading {
align-items:flex-start;
flex-direction:column;
}
.page-heading .actions {
width:100%;
flex-wrap:wrap;
}
.draft-layout {
grid-template-columns:1fr;
}
.draft-layout aside {
display:flex;
flex-direction:column;
}
.turn {
flex-wrap:wrap;
}
.candidate-main {
align-items:flex-start;
flex-wrap:wrap;
}
.candidate-main .actions {
margin-left:auto;
}
.auth {
margin:2rem auto;
}
.auth h1 {
font-size:2.6rem;
}
.race>summary {
align-items:flex-start;
flex-direction:column;
gap:.4rem;
}
.panel {
padding:1.1rem;
}
footer {
padding:1.3rem 1rem;
}
.edition {
font-size:.7rem;
}
}
table {
width:100%;
border-collapse:collapse;
font-size:.85rem;
}
th,td {
text-align:left;
padding:.7rem .3rem;
border-bottom:1px solid var(--line);
}
th {
color:var(--muted);
font-weight:500;
}
td:not(:first-child) {
font-variant-numeric:tabular-nums;
}
+138
View File
@@ -0,0 +1,138 @@
export interface League {
id: string;
name: string;
commissioner_id: string;
max_teams: number;
status: string;
catalog_mode: string | null;
catalog_id: string | null;
ratings_id: string | null;
members: number;
team_name: string;
}
export interface Team {
id: string;
user_id: string;
name: string;
username: string;
draft_order: number | null;
queue?: string[];
}
export interface Pick {
number: number;
team_id: string;
candidate_id: string;
slot: string;
automatic: number;
}
export interface LeagueView {
league: League;
teams: Team[];
my_team: Team & {
queue: string[];
};
picks: Pick[];
draft: {
pick_number: number;
deadline: number | null;
remaining: number;
};
picker: Team | null;
legal_candidate_ids: string[];
candidate_names: Record<string, string>;
server_time: number;
readiness: {
ready: boolean;
problems: string[];
};
}
export interface Snapshot {
id: string;
label: string;
mode: string;
catalog_id?: string;
complete?: boolean;
}
export interface Catalogs {
catalogs: Snapshot[];
ratings: Snapshot[];
allow_demo: boolean;
}
export interface Candidate {
id: string;
race_id: string;
name: string;
affiliation: string;
party: string;
office: string;
state: string;
rating: string | null;
owner: string | null;
source: {
url: string;
name: string;
retrieved_at: string;
};
}
export interface CandidatePage {
items: Candidate[];
total: number;
catalog: Snapshot | null;
}
export interface Provenance {
source_id: string;
source_name: string;
url: string;
source_time: string | null;
received_at: number;
last_checked: number | null;
stale: boolean;
verified: number;
}
export interface Counts {
total_votes: number;
votes: {
candidate_id: string;
votes: number;
}[];
provenance: Provenance;
}
export interface Outcome {
status: string;
round: string;
winner_id: string | null;
provenance: Provenance;
}
export interface RaceResult {
id: string;
name: string;
office: string;
state: string;
candidates: {
id: string;
name: string;
affiliation: string;
}[];
counts: Counts | null;
outcome: Outcome | null;
}
export interface Score {
candidate: Candidate;
slot: string;
points: number | null;
share: number | null;
win_bonus: number;
rating: number | null;
counts: Counts | null;
outcome: Outcome | null;
}
export interface Standing {
team: Team;
roster: Score[];
known_scores: number;
missing_scores: number;
known_subtotal: number | null;
total: number | null;
rank: number | null;
provisional: boolean;
}
+43
View File
@@ -0,0 +1,43 @@
export function escape(value: unknown) { return String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!)); }
export function element<T extends HTMLElement = HTMLElement>(selector: string, root: ParentNode = document): T { const node = root.querySelector<T>(selector); if (!node)
throw new Error(`Missing element: ${selector}`); return node; }
export function message(error: unknown) { if (error instanceof DOMException && error.name === 'AbortError')
return; const region = document.querySelector<HTMLElement>('#notice'); if (region) {
region.textContent = error instanceof Error ? error.message : String(error);
region.hidden = false;
} }
export function clearMessage() { const region = element('#notice'); region.hidden = true; region.textContent = ''; }
export function submit(selector: string, operation: (data: FormData) => Promise<void>) {
element<HTMLFormElement>(selector).addEventListener('submit', async (event) => {
event.preventDefault();
clearMessage();
const form = event.currentTarget as HTMLFormElement;
const button = form.querySelector<HTMLButtonElement>('button[type=submit]');
if (button)
button.disabled = true;
try {
await operation(new FormData(form));
}
catch (error) {
message(error);
}
finally {
if (button)
button.disabled = false;
}
});
}
export function click(selector: string, operation: () => Promise<void>, root: ParentNode = document) { const button = root.querySelector<HTMLButtonElement>(selector); button?.addEventListener('click', async () => { clearMessage(); button.disabled = true; try {
await operation();
}
catch (error) {
message(error);
}
finally {
button.disabled = false;
} }); }
export function field(label: string, name: string, value = '', extra = '') { return `<label>${escape(label)}<input name="${name}" value="${escape(value)}" ${extra} required></label>`; }
export function sourceLink(url: string, label: string) { return /^https?:\/\//.test(url) ? `<a href="${escape(url)}" target="_blank" rel="noopener noreferrer">${escape(label)}</a>` : escape(label); }
export function time(value: number | null) { return value ? new Date(value * 1000).toLocaleString() : 'Not checked'; }
export function points(value: number | null) { return value == null ? '—' : value.toFixed(2); }
export function go(path: string) { location.hash = path; }
+1
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: { rollupOptions: { input: { app: "index.html", lab: "lab.html" } } },
server: {
host: '127.0.0.1',
port: 5173,
+114
View File
@@ -0,0 +1,114 @@
#include "store.hpp"
#include "application.hpp"
#include "domain.hpp"
#include <iostream>
#include <unistd.h>
namespace {
auto execute(fpol::store &store, fpol::application &application, std::filesystem::path path, std::string action, std::vector<std::string> files, boost::json::value request) -> boost::asio::awaitable<void> {
co_await store.initialize();
auto result = boost::json::value{};
if (action == "import") {
for (auto const &file : files) {
std::cout << boost::json::serialize(co_await store.import_document(fpol::read_json(file))) << '\n';
}
co_return;
}
if (action == "create-user" || action == "reset-link") {
result = co_await application.admin(action, request);
} else if (action == "backup") {
fpol::ensure(files.size() == 1 && !std::filesystem::exists(files[0]), "backup requires a new destination filename");
auto database = fpol::database{path};
database.backup(files[0]);
result = boost::json::object{{"backup", files[0]}};
} else if (action == "sources") {
result = co_await application.admin(action, boost::json::object{});
} else if (action == "app-request") {
auto command = fpol::text(request, "action");
if (command == "tick" || command == "restart") {
co_await application.tick(command == "restart");
result = boost::json::object{{"ok", true}};
} else {
auto optional = [&](std::string const &key) {auto item=request.as_object().if_contains(key);return item&&item->is_string()?std::string{item->as_string()}:std::string{}; };
result = co_await application.handle(command, request.as_object().contains("input") ? request.at("input") : boost::json::value{boost::json::object{}}, optional("session"), optional("csrf"), "operator-local");
}
} else {
result = co_await store.handle(action, std::move(request));
}
std::cout << boost::json::serialize(result) << '\n';
}
}
int main(int argc, char **argv) {
if (argc == 2 && std::string_view{argv[1]} == "--help") {
std::cout << "fpol_admin [--database PATH] [--allow-demo] import FILE... | rules | catalog | capacity FILE | score-preview FILE\n"
" create-user USERNAME (password read privately from terminal, or from stdin)\n"
" reset-link USERNAME (single-use token, expires in 30 minutes)\n"
" sources | backup NEW_FILENAME\n"
" app-request FILE|- (trusted local diagnostic API; returns session secrets; never expose publicly)\n";
return 0;
}
try {
auto index = 1;
auto config = fpol::application_config{};
while (index < argc && std::string_view{argv[index]}.starts_with("--")) {
auto option = std::string_view{argv[index++]};
if (option == "--allow-demo") {
config.allow_demo = true;
} else if (option == "--database" && index < argc) {
config.database = argv[index++];
} else {
throw std::invalid_argument{"Invalid option"};
}
}
fpol::ensure(index < argc, "Missing command");
auto action = std::string{argv[index++]};
auto files = std::vector<std::string>{};
while (index < argc) {
files.emplace_back(argv[index++]);
}
auto request = boost::json::value{boost::json::object{}};
if (action == "create-user" || action == "reset-link") {
fpol::ensure(files.size() == 1, "Expected username");
request.as_object()["username"] = files[0];
if (action == "create-user") {
auto password = std::string{};
if (isatty(STDIN_FILENO)) {
auto secret = getpass("New password (12+ characters): ");
fpol::ensure(secret != nullptr, "Cannot read password");
password = secret;
} else {
std::getline(std::cin, password);
}
request.as_object()["password"] = password;
}
} else if (action == "app-request" || action == "capacity" || action == "score-preview") {
fpol::ensure(files.size() == 1, "Expected a request file");
if (files[0] == "-") {
auto body = std::string{std::istreambuf_iterator<char>{std::cin}, {}};
request = boost::json::parse(body);
} else {
request = fpol::read_json(files[0]);
}
if (action == "app-request" && request.as_object().contains("clock")) {
auto time = fpol::integer(request, "clock");
config.clock = [time]() { return time; };
}
} else {
fpol::ensure((action == "import" && !files.empty()) || (action == "backup" && files.size() == 1) || ((action == "catalog" || action == "rules" || action == "sources") && files.empty()), "Invalid command or arguments; use --help");
}
auto io = boost::asio::io_context{};
auto store = fpol::store{io.get_executor(), config.database};
auto application = fpol::application{config};
auto future = boost::asio::co_spawn(io, execute(store, application, config.database, action, files, request), boost::asio::use_future);
io.run();
future.get();
return 0;
} catch (std::exception const &error) {
std::cerr << "Request failed: " << error.what() << '\n';
return 1;
}
}
+307
View File
@@ -0,0 +1,307 @@
#include "application.hpp"
#include "crypto.hpp"
#include "domain.hpp"
#include <algorithm>
#include <chrono>
#include <regex>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/use_awaitable.hpp>
#include <boost/system/system_error.hpp>
namespace {
auto execute(std::function<boost::json::value()> operation) -> boost::asio::awaitable<boost::json::value> {
co_return operation();
}
auto optional_text(boost::json::value const &value, std::string const &key, std::string fallback = {}) -> std::string {
auto item = value.as_object().if_contains(key);
return item && item->is_string() ? std::string{item->as_string()} : fallback;
}
auto lower(std::string value) -> std::string {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { return static_cast<char>(std::tolower(character)); });
return value;
}
auto username(boost::json::value const &input) -> std::string {
auto name = lower(fpol::text(input, "username"));
fpol::ensure(std::regex_match(name, std::regex{"[a-z0-9_]{3,32}"}), "Username must have 3–32 letters, numbers or underscores");
return name;
}
auto authenticated(fpol::database &database, std::string const &token, std::string const &csrf, std::int64_t now, bool mutation) -> boost::json::object {
auto user = database.one("SELECT u.id,u.username,u.admin,s.csrf FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?", {fpol::digest(token), now});
fpol::ensure(!user.empty(), "Please sign in", 401);
if (mutation) {
fpol::ensure(!csrf.empty() && std::string{user.at("csrf").as_string()} == csrf, "Invalid session request; refresh and try again", 403);
}
return user;
}
auto session(fpol::database &database, std::string const &user_id, std::int64_t now) -> boost::json::value {
auto token = fpol::random_token();
auto csrf = fpol::random_token();
database.query("DELETE FROM sessions WHERE expires_at<=?", {now});
database.query("INSERT INTO sessions VALUES(?,?,?,?)", {fpol::digest(token), user_id, csrf, now + 30 * 86400});
auto user = database.one("SELECT id,username,admin FROM users WHERE id=?", {user_id});
user["csrf"] = csrf;
return boost::json::object{{"ok", true}, {"status", 200}, {"payload", user}, {"session_token", token}};
}
auto success(boost::json::value payload) -> boost::json::value {
return boost::json::object{{"ok", true}, {"status", 200}, {"payload", std::move(payload)}};
}
auto mutation(std::string const &action) -> bool {
return action != "auth/me" && action != "leagues" && action != "league" && action != "catalogs" && action != "candidates" && action != "results" && action != "standings" && action != "admin/sources";
}
auto request_catalog(fpol::database &database, boost::json::value const &input, boost::json::object const &user, bool allow_demo) -> boost::json::value {
auto id = optional_text(input, "catalog_id");
auto league = optional_text(input, "league_id");
if (!league.empty()) {
auto row = database.one("SELECT l.catalog_id FROM leagues l JOIN teams t ON t.league_id=l.id WHERE l.id=? AND t.user_id=?", {league, fpol::text(user, "id")});
fpol::ensure(!row.empty(), "This league is private", 403);
id = row.at("catalog_id").is_null() ? "" : fpol::text(row, "catalog_id");
}
auto catalog = id.empty() ? fpol::current_catalog(database) : database.document(id, "catalog");
if (!catalog.is_null()) {
fpol::ensure(fpol::text(catalog, "mode") == "real" || allow_demo, "Demo catalogs are disabled", 403);
}
return catalog;
}
auto number(boost::json::value const &input, std::string const &key, std::int64_t fallback) -> std::int64_t {
auto item = input.as_object().if_contains(key);
if (!item) {
return fallback;
}
if (item->is_int64()) {
return item->as_int64();
}
if (item->is_string()) {
auto value = std::string{item->as_string()};
fpol::ensure(std::regex_match(value, std::regex{"[0-9]{1,6}"}), "Invalid pagination");
return std::stoll(value);
}
throw fpol::api_error{400, "Invalid pagination"};
}
}
fpol::application::application(fpol::application_config config)
: config_{std::move(config)} {}
auto fpol::application::now() const -> std::int64_t {
return config_.clock ? config_.clock() : std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
}
auto fpol::application::work(std::function<boost::json::value(fpol::database &)> operation) -> boost::asio::awaitable<boost::json::value> {
auto path = config_.database;
co_return co_await boost::asio::co_spawn(database_worker_, execute([path, operation = std::move(operation)]() {auto database=fpol::database{path};database.begin();auto value=operation(database);database.commit();return value; }), boost::asio::use_awaitable);
}
auto fpol::application::hash_work(std::function<boost::json::value()> operation) -> boost::asio::awaitable<boost::json::value> {
if (pending_hashes_.fetch_add(1) >= 8) {
--pending_hashes_;
throw fpol::api_error{429, "Too many sign-in requests; try again shortly"};
}
try {
auto result = co_await boost::asio::co_spawn(password_workers_, execute(std::move(operation)), boost::asio::use_awaitable);
--pending_hashes_;
co_return result;
} catch (...) {
--pending_hashes_;
throw;
}
}
auto fpol::application::tick(bool restart) -> boost::asio::awaitable<void> {
auto time = now();
co_await work([time, restart](fpol::database &database) {fpol::advance_drafts(database,time,restart);return boost::json::value{}; });
}
auto fpol::application::handle(std::string action, boost::json::value input, std::string token, std::string csrf, std::string peer) -> boost::asio::awaitable<boost::json::value> {
try {
ensure(input.is_object(), "Request must be an object");
auto time = now();
if (action == "auth/login") {
auto name = username(input);
auto password = fpol::text(input, "password");
ensure(password.size() <= 256, "Invalid credentials", 401);
auto user = co_await work([name, peer, time](fpol::database &database) {
for (auto const &key : {"user:" + name, "peer:" + peer}) {
auto limit = database.one("SELECT * FROM login_limits WHERE key=?", {key});
auto maximum = key.starts_with("user:") ? 10 : 100;
ensure(limit.empty() || time - limit.at("window_start").as_int64() >= 900 || limit.at("failures").as_int64() < maximum, "Too many login attempts; try again in 15 minutes", 429);
}
for (auto const &key : {"user:" + name, "peer:" + peer}) {
database.query("INSERT INTO login_limits VALUES(?,1,?) ON CONFLICT(key) DO UPDATE SET failures=CASE WHEN ?-window_start>=900 THEN 1 ELSE failures+1 END,window_start=CASE WHEN ?-window_start>=900 THEN ? ELSE window_start END", {key, time, time, time, time});
}
return boost::json::value{database.one("SELECT * FROM users WHERE username=?", {name})};
});
auto hash = user.as_object().empty() ? "scrypt$131072$8$1$" + std::string(32, '0') + "$" + std::string(128, '0') : fpol::text(user, "password_hash");
auto valid = co_await hash_work([password, hash]() { return boost::json::value{fpol::password_matches(password, hash)}; });
ensure(valid.as_bool() && !user.as_object().empty(), "Invalid username or password", 401);
co_return co_await work([user, hash, name, time](fpol::database &database) {auto current=database.one("SELECT password_hash FROM users WHERE id=?",{fpol::text(user,"id")});ensure(!current.empty()&&fpol::text(current,"password_hash")==hash,"Password changed; sign in again",401);database.query("DELETE FROM login_limits WHERE key=?",{"user:"+name});return session(database,fpol::text(user,"id"),time); });
}
if (action == "auth/register") {
auto name = username(input);
auto invite = fpol::text(input, "invite");
auto password = fpol::text(input, "password");
auto team_name = fpol::text(input, "team_name");
co_await work([name, invite, time](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username is unavailable",409);ensure(!database.one("SELECT token_hash FROM invites WHERE token_hash=? AND expires_at>?",{fpol::digest(invite),time}).empty(),"Invitation is invalid or expired",404);return boost::json::value{}; });
auto hash = co_await hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; });
co_return co_await work([=, this](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username is unavailable",409);auto id=fpol::random_token(12);database.query("INSERT INTO users(id,username,password_hash,created_at) VALUES(?,?,?,?)",{id,name,std::string{hash.as_string()},time});auto user=database.one("SELECT id,username,admin FROM users WHERE id=?",{id});game_action(database,"league/join",boost::json::object{{"invite",invite},{"team_name",team_name}},user,time,config_.allow_demo);return session(database,id,time); });
}
if (action == "auth/reset") {
auto reset = fpol::text(input, "reset_token");
auto password = fpol::text(input, "password");
auto previous = co_await work([reset, time](fpol::database &database) {auto value=database.one("SELECT user_id FROM password_resets WHERE token_hash=? AND expires_at>?",{fpol::digest(reset),time});ensure(!value.empty(),"Reset link is invalid or expired",400);return boost::json::value{value}; });
auto hash = co_await hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; });
co_return co_await work([reset, hash, time, previous](fpol::database &database) {auto value=database.one("SELECT user_id FROM password_resets WHERE token_hash=? AND expires_at>?",{fpol::digest(reset),time});ensure(!value.empty(),"Reset link is invalid or expired",400);auto id=fpol::text(previous,"user_id");database.query("UPDATE users SET password_hash=? WHERE id=?",{std::string{hash.as_string()},id});database.query("DELETE FROM sessions WHERE user_id=?",{id});database.query("DELETE FROM password_resets WHERE user_id=?",{id});return success(boost::json::object{{"message","Password reset. Sign in with your new password."}}); });
}
if (action == "auth/password") {
auto previous = co_await work([=](fpol::database &database) {auto user=authenticated(database,token,csrf,time,true);return boost::json::value{database.one("SELECT id,password_hash FROM users WHERE id=?",{fpol::text(user,"id")})}; });
auto old_password = fpol::text(input, "current_password");
auto password = fpol::text(input, "password");
auto hash = co_await hash_work([previous, old_password, password]() {ensure(fpol::password_matches(old_password,fpol::text(previous,"password_hash")),"Current password is incorrect",401);return boost::json::value{fpol::password_hash(password)}; });
co_return co_await work([=](fpol::database &database) {auto user=authenticated(database,token,csrf,time,true);auto current=database.one("SELECT password_hash FROM users WHERE id=?",{fpol::text(user,"id")});ensure(fpol::text(current,"password_hash")==fpol::text(previous,"password_hash"),"Password changed; sign in again",409);database.query("UPDATE users SET password_hash=? WHERE id=?",{std::string{hash.as_string()},fpol::text(user,"id")});database.query("DELETE FROM sessions WHERE user_id=?",{fpol::text(user,"id")});return session(database,fpol::text(user,"id"),time); });
}
co_return co_await work([=, this](fpol::database &database) {
auto user = authenticated(database, token, csrf, time, mutation(action));
if (action == "auth/me") {
return success(user);
}
if (action == "auth/logout") {
database.query("DELETE FROM sessions WHERE token_hash=?", {fpol::digest(token)});
auto result = success(boost::json::object{});
result.as_object()["clear_session"] = true;
return result;
}
if (action == "admin/sources") {
ensure(user.at("admin").as_int64() == 1, "Administrator access required", 403);
return success(boost::json::object{{"sources", database.query("SELECT id,kind,url,enabled,verified,last_checked,last_success,last_changed,last_error,next_check FROM sources ORDER BY id")}, {"quarantine", database.query("SELECT id,source_id,retrieved_at,reason FROM source_quarantine ORDER BY id DESC LIMIT 50")}, {"coverage", database.query("SELECT * FROM coverage ORDER BY race_id")}});
}
if (action == "catalogs") {
auto catalogs = boost::json::array{};
auto ratings = boost::json::array{};
for (auto const &row : database.query("SELECT body FROM documents WHERE kind IN ('catalog','ratings') ORDER BY as_of DESC,id DESC")) {
auto document = boost::json::parse(fpol::text(row, "body"));
if (fpol::text(document, "mode") == "demo" && !config_.allow_demo) {
continue;
}
auto summary = boost::json::object{{"id", document.at("id")}, {"label", document.at("label")}, {"as_of", document.at("as_of")}, {"mode", document.at("mode")}};
if (fpol::text(document, "kind") == "catalog") {
summary["complete"] = document.at("complete");
catalogs.emplace_back(summary);
} else {
summary["catalog_id"] = document.at("catalog_id");
ratings.emplace_back(summary);
}
}
return success(boost::json::object{{"catalogs", catalogs}, {"ratings", ratings}, {"allow_demo", config_.allow_demo}});
}
if (action == "candidates") {
auto catalog = request_catalog(database, input, user, config_.allow_demo);
if (catalog.is_null()) {
return success(boost::json::object{{"items", boost::json::array{}}, {"total", 0}, {"catalog", nullptr}});
}
auto model = fpol::read_catalog(catalog);
auto rated = std::map<std::string, std::string>{};
auto rating_id = optional_text(input, "ratings_id");
auto league_id = optional_text(input, "league_id");
auto owned = std::map<std::string, boost::json::value>{};
if (!league_id.empty()) {
auto league = database.one("SELECT ratings_id FROM leagues WHERE id=?", {league_id});
if (!league.at("ratings_id").is_null()) {
rating_id = fpol::text(league, "ratings_id");
}
for (auto const &pick : database.query("SELECT p.candidate_id,t.name FROM picks p JOIN teams t ON t.id=p.team_id WHERE p.league_id=?", {league_id})) {
owned.emplace(fpol::text(pick, "candidate_id"), pick.at("name"));
}
}
if (!rating_id.empty()) {
auto ratings = database.document(rating_id, "ratings");
fpol::validate_document(ratings, &model);
for (auto const &entry : ratings.at("entries").as_array()) {
rated.emplace(fpol::text(entry, "race_id"), fpol::text(entry, "rating"));
}
}
auto items = boost::json::array{};
auto search = lower(optional_text(input, "search"));
for (auto const &value : catalog.at("candidates").as_array()) {
auto candidate = value.as_object();
auto race = model.races.at(fpol::text(value, "race_id"));
auto id = fpol::text(value, "id");
if (!search.empty() && lower(fpol::text(value, "name") + " " + race.state + " " + race.id).find(search) == std::string::npos) {
continue;
}
if (!optional_text(input, "office").empty() && optional_text(input, "office") != race.office) {
continue;
}
if (!optional_text(input, "state").empty() && optional_text(input, "state") != race.state) {
continue;
}
if (!optional_text(input, "affiliation").empty() && optional_text(input, "affiliation") != fpol::text(value, "affiliation")) {
continue;
}
if (optional_text(input, "available") == "true" && owned.contains(id)) {
continue;
}
candidate["office"] = race.office;
candidate["state"] = race.state;
candidate["rating"] = rated.contains(race.id) ? boost::json::value{rated.at(race.id)} : boost::json::value{};
candidate["owner"] = owned.contains(id) ? owned.at(id) : boost::json::value{};
items.emplace_back(candidate);
}
auto offset = number(input, "offset", 0);
auto limit = number(input, "limit", 50);
ensure(offset >= 0 && offset <= 100000 && limit >= 1 && limit <= 100, "Invalid pagination");
auto total = items.size();
auto page = boost::json::array{};
for (auto index = static_cast<std::size_t>(offset); index < items.size() && page.size() < static_cast<std::size_t>(limit); ++index) {
page.emplace_back(items[index]);
}
return success(boost::json::object{{"items", page}, {"total", total}, {"catalog", boost::json::object{{"id", model.id}, {"label", catalog.at("label")}, {"complete", model.complete}, {"mode", model.mode}}}});
}
if (action == "results") {
auto catalog = request_catalog(database, input, user, config_.allow_demo);
return success(catalog.is_null() ? boost::json::value{boost::json::object{{"races", boost::json::array{}}}} : current_results(database, catalog));
}
return success(fpol::game_action(database, action, input, user, time, config_.allow_demo));
});
} catch (fpol::api_error const &error) {
co_return boost::json::object{{"ok", false}, {"status", error.status}, {"message", error.what()}, {"code", std::to_string(error.status)}};
} catch (std::invalid_argument const &error) {
co_return boost::json::object{{"ok", false}, {"status", 400}, {"message", error.what()}, {"code", "invalid_argument"}};
} catch (std::out_of_range const &) {
co_return boost::json::object{{"ok", false}, {"status", 400}, {"message", "Missing or invalid request field"}, {"code", "invalid_argument"}};
} catch (boost::system::system_error const &error) {
if (std::string_view{error.code().category().name()} == "boost.json") {
co_return boost::json::object{{"ok", false}, {"status", 400}, {"message", "Missing or invalid request field"}, {"code", "invalid_argument"}};
}
co_return boost::json::object{{"ok", false}, {"status", 500}, {"message", "The request could not be completed"}, {"code", "internal_error"}};
} catch (std::exception const &) {
co_return boost::json::object{{"ok", false}, {"status", 500}, {"message", "The request could not be completed"}, {"code", "internal_error"}};
}
}
auto fpol::application::admin(std::string action, boost::json::value input) -> boost::asio::awaitable<boost::json::value> {
auto time = now();
if (action == "create-user") {
auto name = username(input);
auto password = fpol::text(input, "password");
auto hash = co_await hash_work([password]() { return boost::json::value{fpol::password_hash(password)}; });
co_return co_await work([name, hash, time](fpol::database &database) {ensure(database.one("SELECT id FROM users WHERE username=?",{name}).empty(),"Username already exists",409);auto id=fpol::random_token(12);database.query("INSERT INTO users(id,username,password_hash,admin,created_at) VALUES(?,?,?,1,?)",{id,name,std::string{hash.as_string()},time});return boost::json::value{boost::json::object{{"id",id},{"username",name}}}; });
}
if (action == "reset-link") {
auto name = username(input);
co_return co_await work([name, time](fpol::database &database) {auto user=database.one("SELECT id FROM users WHERE username=?",{name});ensure(!user.empty(),"Unknown account",404);auto token=fpol::random_token();auto id=fpol::text(user,"id");database.query("DELETE FROM password_resets WHERE user_id=?",{id});database.query("INSERT INTO password_resets VALUES(?,?,?)",{fpol::digest(token),id,time+1800});return boost::json::value{boost::json::object{{"reset_token",token},{"expires_at",time+1800}}}; });
}
if (action == "sources") {
co_return co_await work([](fpol::database &database) { return boost::json::value{database.query("SELECT * FROM sources ORDER BY id")}; });
}
throw fpol::api_error{400, "Unknown administrative action"};
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "database.hpp"
#include <atomic>
#include <functional>
#include <boost/asio/thread_pool.hpp>
namespace fpol {
struct application_config {
std::filesystem::path database{"var/fpol.sqlite"};
bool allow_demo{false};
std::function<std::int64_t()> clock{};
};
class application {
public:
explicit application(fpol::application_config config);
auto handle(std::string action, boost::json::value input, std::string session = {}, std::string csrf = {}, std::string peer = {}) -> boost::asio::awaitable<boost::json::value>;
auto tick(bool restart = false) -> boost::asio::awaitable<void>;
auto admin(std::string action, boost::json::value input) -> boost::asio::awaitable<boost::json::value>;
private:
auto work(std::function<boost::json::value(fpol::database &)> operation) -> boost::asio::awaitable<boost::json::value>;
auto hash_work(std::function<boost::json::value()> operation) -> boost::asio::awaitable<boost::json::value>;
auto now() const -> std::int64_t;
fpol::application_config config_;
boost::asio::thread_pool database_worker_{1};
boost::asio::thread_pool password_workers_{2};
std::atomic<unsigned> pending_hashes_{};
};
auto readiness(fpol::database &database, boost::json::object const &league, std::size_t players, bool allow_demo) -> boost::json::value;
auto league_view(fpol::database &database, std::string const &league_id, std::string const &user_id, std::int64_t now, bool allow_demo) -> boost::json::value;
auto game_action(fpol::database &database, std::string const &action, boost::json::value const &input, boost::json::object const &user, std::int64_t now, bool allow_demo) -> boost::json::value;
auto advance_drafts(fpol::database &database, std::int64_t now, bool restart) -> void;
auto current_catalog(fpol::database &database, bool demo = false) -> boost::json::value;
auto current_results(fpol::database &database, boost::json::value const &catalog) -> boost::json::value;
}
+65
View File
@@ -0,0 +1,65 @@
#include "crypto.hpp"
#include "database.hpp"
#include <array>
#include <charconv>
#include <limits>
#include <openssl/crypto.h>
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/sha.h>
namespace {
auto hex(unsigned char const *data, std::size_t size) -> std::string {
auto output = std::string{};
for (std::size_t index = 0; index < size; ++index) {
output += "0123456789abcdef"[data[index] >> 4];
output += "0123456789abcdef"[data[index] & 15];
}
return output;
}
auto derive(std::string const &password, std::string const &salt) -> std::string {
auto output = std::array<unsigned char, 64>{};
fpol::ensure(EVP_PBE_scrypt(password.data(), password.size(), reinterpret_cast<unsigned char const *>(salt.data()), salt.size(), 131072, 8, 1, 256ULL * 1024 * 1024, output.data(), output.size()) == 1, "Password hashing failed", 500);
return hex(output.data(), output.size());
}
}
auto fpol::random_token(std::size_t bytes) -> std::string {
auto buffer = std::vector<unsigned char>(bytes);
ensure(RAND_bytes(buffer.data(), static_cast<int>(buffer.size())) == 1, "Random generator failed", 500);
return hex(buffer.data(), buffer.size());
}
auto fpol::digest(std::string_view input) -> std::string {
auto output = std::array<unsigned char, SHA256_DIGEST_LENGTH>{};
SHA256(reinterpret_cast<unsigned char const *>(input.data()), input.size(), output.data());
return hex(output.data(), output.size());
}
auto fpol::password_hash(std::string const &password) -> std::string {
ensure(password.size() >= 12 && password.size() <= 256, "Use a password between 12 and 256 bytes");
auto salt = random_token(16);
return "scrypt$131072$8$1$" + salt + "$" + derive(password, salt);
}
auto fpol::password_matches(std::string const &password, std::string const &hash) -> bool {
if (password.size() > 256 || !hash.starts_with("scrypt$131072$8$1$") || hash.size() != 18 + 32 + 1 + 128) {
return false;
}
auto expected = derive(password, hash.substr(18, 32));
return CRYPTO_memcmp(expected.data(), hash.data() + 51, 128) == 0;
}
auto fpol::random_index(std::size_t size) -> std::size_t {
ensure(size > 0, "Cannot choose from an empty list", 500);
auto value = std::uint64_t{};
auto limit = std::numeric_limits<std::uint64_t>::max() - std::numeric_limits<std::uint64_t>::max() % size;
do {
ensure(RAND_bytes(reinterpret_cast<unsigned char *>(&value), sizeof(value)) == 1, "Random generator failed", 500);
} while (value >= limit);
return static_cast<std::size_t>(value % size);
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <cstddef>
#include <string>
#include <string_view>
namespace fpol {
auto random_token(std::size_t bytes = 32) -> std::string;
auto digest(std::string_view input) -> std::string;
auto password_hash(std::string const &password) -> std::string;
auto password_matches(std::string const &password, std::string const &hash) -> bool;
auto random_index(std::size_t size) -> std::size_t;
}
+110
View File
@@ -0,0 +1,110 @@
#include "database.hpp"
namespace {
auto migrate(noto::connection &connection) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto statements = std::vector<std::string>{
"CREATE TABLE users(id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE, password_hash TEXT NOT NULL, admin INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL)",
"CREATE TABLE sessions(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id),csrf TEXT NOT NULL,expires_at INTEGER NOT NULL)",
"CREATE TABLE login_limits(key TEXT PRIMARY KEY,failures INTEGER NOT NULL,window_start INTEGER NOT NULL)",
"CREATE TABLE password_resets(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id),expires_at INTEGER NOT NULL)",
"CREATE TABLE leagues(id TEXT PRIMARY KEY,name TEXT NOT NULL,commissioner_id TEXT NOT NULL REFERENCES users(id),max_teams INTEGER NOT NULL CHECK(max_teams BETWEEN 2 AND 12),status TEXT NOT NULL DEFAULT 'forming',catalog_id TEXT REFERENCES documents(id),ratings_id TEXT REFERENCES documents(id),revision INTEGER NOT NULL DEFAULT 0,rules_json TEXT,created_at INTEGER NOT NULL)",
"CREATE TABLE teams(id TEXT PRIMARY KEY,league_id TEXT NOT NULL REFERENCES leagues(id),user_id TEXT NOT NULL REFERENCES users(id),name TEXT NOT NULL,draft_order INTEGER,queue TEXT NOT NULL DEFAULT '[]',UNIQUE(league_id,user_id))",
"CREATE TABLE invites(token_hash TEXT PRIMARY KEY,league_id TEXT NOT NULL REFERENCES leagues(id),expires_at INTEGER NOT NULL)",
"CREATE TABLE drafts(league_id TEXT PRIMARY KEY REFERENCES leagues(id),pick_number INTEGER NOT NULL DEFAULT 0,deadline INTEGER,remaining INTEGER NOT NULL DEFAULT 120)",
"CREATE TABLE picks(league_id TEXT NOT NULL REFERENCES leagues(id),number INTEGER NOT NULL,team_id TEXT NOT NULL REFERENCES teams(id),candidate_id TEXT NOT NULL,slot TEXT NOT NULL,automatic INTEGER NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(league_id,number),UNIQUE(league_id,candidate_id),UNIQUE(team_id,slot))",
"CREATE TABLE commands(league_id TEXT NOT NULL REFERENCES leagues(id),user_id TEXT NOT NULL REFERENCES users(id),request_id TEXT NOT NULL,request_hash TEXT NOT NULL,response TEXT NOT NULL,PRIMARY KEY(league_id,user_id,request_id))",
"CREATE TABLE events(id INTEGER PRIMARY KEY AUTOINCREMENT,league_id TEXT REFERENCES leagues(id),kind TEXT NOT NULL,body TEXT NOT NULL,created_at INTEGER NOT NULL)",
"CREATE INDEX team_memberships ON teams(user_id,league_id)",
"CREATE INDEX league_events ON events(league_id,id)",
"CREATE TABLE sources(id TEXT PRIMARY KEY,kind TEXT NOT NULL,url TEXT NOT NULL,config TEXT NOT NULL,enabled INTEGER NOT NULL DEFAULT 0,verified INTEGER NOT NULL DEFAULT 0,last_checked INTEGER,last_success INTEGER,last_changed INTEGER,last_error TEXT,etag TEXT,last_modified TEXT,last_payload TEXT,next_check INTEGER NOT NULL DEFAULT 0,failures INTEGER NOT NULL DEFAULT 0)",
"CREATE TABLE source_checks(source_id TEXT NOT NULL REFERENCES sources(id),race_id TEXT NOT NULL,kind TEXT NOT NULL,checked_at INTEGER NOT NULL,PRIMARY KEY(source_id,race_id,kind))",
"CREATE TABLE source_payloads(id TEXT PRIMARY KEY,source_id TEXT NOT NULL REFERENCES sources(id),retrieved_at INTEGER NOT NULL,body TEXT NOT NULL)",
"CREATE TABLE observations(id TEXT PRIMARY KEY,source_id TEXT NOT NULL REFERENCES sources(id),race_id TEXT NOT NULL,kind TEXT NOT NULL,source_time TEXT,received_at INTEGER NOT NULL,body TEXT NOT NULL)",
"CREATE INDEX race_observations ON observations(race_id,kind,received_at)",
"CREATE TABLE current_observations(race_id TEXT NOT NULL,kind TEXT NOT NULL,observation_id TEXT NOT NULL REFERENCES observations(id),PRIMARY KEY(race_id,kind))",
"CREATE TABLE coverage(race_id TEXT PRIMARY KEY,counts_source TEXT REFERENCES sources(id),outcome_source TEXT REFERENCES sources(id))",
"CREATE TABLE source_quarantine(id INTEGER PRIMARY KEY AUTOINCREMENT,source_id TEXT NOT NULL REFERENCES sources(id),retrieved_at INTEGER NOT NULL,reason TEXT NOT NULL,payload_id TEXT REFERENCES source_payloads(id))"};
for (auto const &statement : statements) {
auto result = co_await connection.async_execute(statement);
if (!result) {
co_return std::unexpected(result.error());
}
}
co_return std::expected<void, modulus::error>{};
}
}
auto fpol::game_migration() -> noto::migration {
return {"002_leagues", "Accounts, private leagues, drafts and source observations", "002-v1", migrate, {}};
}
auto fpol::ensure(bool condition, std::string const &message, int status) -> void {
if (!condition) {
throw fpol::api_error{status, message};
}
}
fpol::database::database(std::filesystem::path const &path)
: connection_{io_.get_executor(), noto::file_connection_config(path.string())} {
wait(connection_.async_connect());
wait(connection_.async_set_foreign_keys(true));
wait(connection_.async_set_busy_timeout(std::chrono::milliseconds{3000}));
}
fpol::database::~database() {
try {
if (transaction_) {
query("ROLLBACK");
}
wait(connection_.async_close());
} catch (...) {
}
}
auto fpol::database::query(std::string sql, std::vector<noto::sqlite_value> parameters) -> boost::json::array {
auto result = wait(connection_.async_execute(std::move(sql), std::move(parameters)));
auto rows = boost::json::array{};
for (auto const &row : result.rows) {
auto object = boost::json::object{};
for (std::size_t index = 0; index < row.size(); ++index) {
std::visit([&](auto const &value) {
if constexpr (std::is_same_v<std::decay_t<decltype(value)>, std::monostate>) {
object[result.columns[index]] = nullptr;
} else if constexpr (!std::is_same_v<std::decay_t<decltype(value)>, std::vector<std::byte>>) {
object[result.columns[index]] = value;
}
},
row[index].value);
}
rows.emplace_back(std::move(object));
}
return rows;
}
auto fpol::database::one(std::string sql, std::vector<noto::sqlite_value> parameters) -> boost::json::object {
auto rows = query(std::move(sql), std::move(parameters));
return rows.empty() ? boost::json::object{} : rows.front().as_object();
}
auto fpol::database::document(std::string const &id, std::string const &kind) -> boost::json::value {
auto row = one("SELECT body FROM documents WHERE id=? AND kind=?", {id, kind});
fpol::ensure(!row.empty(), "Unknown " + kind + " snapshot", 404);
return boost::json::parse(row.at("body").as_string());
}
auto fpol::database::begin() -> void {
query("BEGIN IMMEDIATE");
transaction_ = true;
}
auto fpol::database::commit() -> void {
query("COMMIT");
transaction_ = false;
}
auto fpol::database::backup(std::string const &path) -> void {
wait(connection_.async_backup_to_file(path));
}
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <filesystem>
#include <stdexcept>
#include <boost/asio/io_context.hpp>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/use_future.hpp>
#include <boost/json.hpp>
#include <noto.hpp>
namespace fpol {
class api_error : public std::runtime_error {
public:
api_error(int status, std::string message)
: std::runtime_error{std::move(message)}, status{status} {}
int status;
};
auto ensure(bool condition, std::string const &message, int status = 400) -> void;
auto game_migration() -> noto::migration;
class database {
public:
explicit database(std::filesystem::path const &path);
~database();
auto query(std::string sql, std::vector<noto::sqlite_value> parameters = {}) -> boost::json::array;
auto one(std::string sql, std::vector<noto::sqlite_value> parameters = {}) -> boost::json::object;
auto document(std::string const &id, std::string const &kind) -> boost::json::value;
auto begin() -> void;
auto commit() -> void;
auto backup(std::string const &path) -> void;
private:
template <typename VALUE>
auto wait(boost::asio::awaitable<std::expected<VALUE, modulus::error>> operation) -> VALUE {
io_.restart();
auto future = boost::asio::co_spawn(io_, std::move(operation), boost::asio::use_future);
io_.run();
auto result = future.get();
if (!result) {
throw std::runtime_error(result.error().describe());
}
if constexpr (!std::is_void_v<VALUE>) {
return std::move(*result);
}
}
boost::asio::io_context io_;
noto::connection connection_;
bool transaction_{};
};
}
+323
View File
@@ -0,0 +1,323 @@
#include "domain.hpp"
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <regex>
#include <set>
#include <stdexcept>
namespace {
auto const offices = std::array<std::string, 3>{"house", "senate", "governor"};
auto const affiliations = std::array<std::string, 4>{"democrat", "republican", "other", "flex"};
auto const multipliers = std::map<std::string, std::int64_t>{{"safe", 100}, {"likely", 110}, {"lean", 125}, {"toss_up", 150}};
auto require(bool condition, std::string const &message) -> void {
if (!condition) {
throw std::invalid_argument(message);
}
}
auto array(boost::json::value const &value, std::string const &key) -> boost::json::array const & {
require(value.is_object() && value.as_object().contains(key) && value.at(key).is_array(), key + " must be an array");
return value.at(key).as_array();
}
auto timestamp(std::string const &value) -> void {
require(std::regex_match(value, std::regex{"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z"}), "Timestamp must use UTC YYYY-MM-DDTHH:MM:SSZ");
auto day = std::chrono::year_month_day{std::chrono::year{std::stoi(value.substr(0, 4))}, std::chrono::month{static_cast<unsigned>(std::stoi(value.substr(5, 2)))}, std::chrono::day{static_cast<unsigned>(std::stoi(value.substr(8, 2)))}};
require(day.ok() && std::stoi(value.substr(11, 2)) < 24 && std::stoi(value.substr(14, 2)) < 60 && std::stoi(value.substr(17, 2)) < 60, "Invalid timestamp");
}
auto provenance(boost::json::value const &value, std::string const &mode) -> void {
auto url = fpol::text(value, "url");
require(url.starts_with("https://") || url.starts_with("http://") || (mode == "demo" && url.starts_with("fixture:")), "Source must be an HTTP(S) URL; fixture: is only allowed for demo data");
fpol::text(value, "name");
timestamp(fpol::text(value, "retrieved_at"));
}
auto header(boost::json::value const &document) -> void {
require(fpol::integer(document, "schema_version") == 1, "Unsupported schema_version");
auto id = fpol::text(document, "id");
require(std::regex_match(id, std::regex{"[a-zA-Z0-9_.:-]{1,120}"}), "Invalid document ID");
auto mode = fpol::text(document, "mode");
require(mode == "demo" || mode == "real", "mode must be demo or real");
fpol::text(document, "label");
timestamp(fpol::text(document, "as_of"));
provenance(document.at("source"), mode);
}
auto cents(std::int64_t votes, std::int64_t total, bool winner, std::int64_t multiplier) -> std::int64_t {
// Exact half-up rounding, once per candidate. Bounds on totals keep this in int64.
auto numerator = (votes * 100 + (winner ? total * 50 : 0)) * multiplier;
return (numerator + total / 2) / total;
}
auto money(std::int64_t value) -> double {
return static_cast<double>(value) / 100.0;
}
}
auto fpol::text(boost::json::value const &value, std::string const &key) -> std::string {
require(value.is_object() && value.as_object().contains(key) && value.at(key).is_string(), key + " must be a string");
auto result = std::string{value.at(key).as_string()};
require(!result.empty(), key + " must not be empty");
return result;
}
auto fpol::integer(boost::json::value const &value, std::string const &key) -> std::int64_t {
require(value.is_object() && value.as_object().contains(key) && value.at(key).is_int64(), key + " must be an integer");
return value.at(key).as_int64();
}
auto fpol::read_catalog(boost::json::value const &document) -> fpol::catalog {
header(document);
require(text(document, "kind") == "catalog", "Expected catalog document");
require(document.at("complete").is_bool(), "complete must be a boolean");
auto result = fpol::catalog{text(document, "id"), text(document, "mode"), document.at("complete").as_bool(), {}, {}};
auto const states = std::set<std::string>{"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY"};
for (auto const &item : array(document, "races")) {
auto race = fpol::race{text(item, "id"), text(item, "office"), text(item, "state"), text(item, "round")};
text(item, "name");
require(std::ranges::find(offices, race.office) != offices.end(), "Unknown office: " + race.office);
require(states.contains(race.state), "Unknown state: " + race.state);
require(result.races.emplace(race.id, race).second, "Duplicate race ID: " + race.id);
}
for (auto const &item : array(document, "candidates")) {
auto candidate = fpol::candidate{text(item, "id"), text(item, "race_id"), text(item, "affiliation")};
text(item, "name");
require(candidate.affiliation == "democrat" || candidate.affiliation == "republican" || candidate.affiliation == "other", "Unknown candidate affiliation");
require(result.races.contains(candidate.race_id), "Unknown candidate race: " + candidate.race_id);
require(item.at("qualified").is_bool() && item.at("qualified").as_bool(), "Only individually qualified ballot candidates can be imported");
require(!array(item, "ballot_lines").empty(), "ballot_lines must identify original party labels");
for (auto const &line : item.at("ballot_lines").as_array()) {
require(line.is_string() && !line.as_string().empty(), "Invalid ballot line");
}
provenance(item.at("source"), result.mode);
require(item.at("provider_ids").is_object(), "provider_ids must be an object");
for (auto const &mapping : item.at("provider_ids").as_object()) {
require(mapping.value().is_string() && !mapping.value().as_string().empty(), "Provider IDs must be nonempty strings");
}
require(result.candidates.emplace(candidate.id, candidate).second, "Duplicate candidate ID (merge fusion ballot lines): " + candidate.id);
}
// A provider's identifier cannot map to two different internal candidates.
auto mappings = std::set<std::pair<std::string, std::string>>{};
for (auto const &item : array(document, "candidates")) {
for (auto const &mapping : item.at("provider_ids").as_object()) {
require(mappings.emplace(std::string{mapping.key()}, std::string{mapping.value().as_string()}).second, "Duplicate provider candidate mapping");
}
}
return result;
}
auto fpol::validate_document(boost::json::value const &document, fpol::catalog const *catalog) -> void {
header(document);
auto kind = text(document, "kind");
if (kind == "catalog") {
read_catalog(document);
return;
}
require(kind == "ratings" || kind == "results", "kind must be catalog, ratings, or results");
require(catalog != nullptr, "Import the referenced catalog first");
require(text(document, "catalog_id") == catalog->id && text(document, "mode") == catalog->mode, "Snapshot and catalog identity/mode mismatch");
auto races = std::set<std::string>{};
for (auto const &entry : array(document, "entries")) {
auto race_id = text(entry, "race_id");
require(catalog->races.contains(race_id), "Unknown race: " + race_id);
require(races.insert(race_id).second, "Duplicate race entry: " + race_id);
if (kind == "ratings") {
require(multipliers.contains(text(entry, "rating")), "Unknown rating");
continue;
}
require(text(entry, "round") == catalog->races.at(race_id).round, "Result round differs from the catalog; never combine rounds");
auto status = text(entry, "status");
require(status == "reporting" || status == "called" || status == "certified" || status == "pending_round", "Unknown result status");
require(entry.at("total_votes").is_null() || (integer(entry, "total_votes") >= 0 && integer(entry, "total_votes") <= 1000000000), "Invalid total_votes (maximum 1 billion)");
auto total = entry.at("total_votes").is_null() ? 0 : integer(entry, "total_votes");
auto sum = std::int64_t{};
auto candidates = std::set<std::string>{};
for (auto const &vote : array(entry, "votes")) {
auto id = text(vote, "candidate_id");
require(catalog->candidates.contains(id) && catalog->candidates.at(id).race_id == race_id, "Vote candidate does not belong to race: " + id);
require(candidates.insert(id).second, "Duplicate candidate vote row; consolidate fusion lines first");
auto count = integer(vote, "votes");
require(count >= 0 && count <= 1000000000, "Invalid vote count");
sum += count;
}
require(sum <= total, "Candidate votes exceed the race total");
require(entry.at("winner_id").is_null() || entry.at("winner_id").is_string(), "winner_id must be a candidate ID or null");
auto has_winner = !entry.at("winner_id").is_null();
require(has_winner == (status == "called" || status == "certified"), "Only called/certified races must have a winner; reporting/pending_round must not");
if (has_winner) {
auto winner = text(entry, "winner_id");
require(catalog->candidates.contains(winner) && catalog->candidates.at(winner).race_id == race_id, "Winner does not belong to race");
}
}
}
auto fpol::rules() -> boost::json::value {
auto slots = boost::json::array{};
for (auto const &office : offices) {
for (auto const &affiliation : affiliations) {
slots.emplace_back(boost::json::object{{"id", office + ":" + affiliation}, {"office", office}, {"affiliation", affiliation}});
}
}
return boost::json::object{{"version", "v1"}, {"min_players", 2}, {"max_players", 12}, {"slots", slots}, {"win_bonus", 50}, {"multipliers", boost::json::object{{"safe", 1.0}, {"likely", 1.1}, {"lean", 1.25}, {"toss_up", 1.5}}}, {"formula", "(vote share percentage + awarded win bonus) × frozen race multiplier"}, {"rounding", "Round each candidate to 2 decimals, half up, then sum. Missing scores are unknown, not zero."}};
}
auto fpol::capacity(fpol::catalog const &catalog, std::int64_t players) -> boost::json::value {
require(players >= 2 && players <= 12, "players must be between 2 and 12");
auto rows = boost::json::array{};
auto sufficient = true;
for (auto const &office : offices) {
auto counts = std::map<std::string, std::int64_t>{{"democrat", 0}, {"republican", 0}, {"other", 0}, {"total", 0}};
for (auto const &[id, candidate] : catalog.candidates) {
if (catalog.races.at(candidate.race_id).office == office) {
++counts.at(candidate.affiliation);
++counts.at("total");
}
}
auto requirements = boost::json::array{};
for (auto const &[group, count] : counts) {
auto needed = players * (group == "total" ? 4 : 1);
sufficient = sufficient && count >= needed;
requirements.emplace_back(boost::json::object{{"group", group}, {"available", count}, {"required", needed}, {"shortfall", std::max<std::int64_t>(0, needed - count)}});
}
rows.emplace_back(boost::json::object{{"office", office}, {"requirements", requirements}});
}
return boost::json::object{{"catalog_id", catalog.id}, {"players", players}, {"offices", rows}, {"sufficient_candidates", sufficient}, {"catalog_complete", catalog.complete}, {"candidate_supply_ready", sufficient && catalog.complete && catalog.mode == "real"}, {"note", "Capacity only. Complete, verified ratings and results coverage are separate launch gates."}};
}
auto fpol::score_preview(fpol::catalog const &catalog, boost::json::value const &ratings, boost::json::value const &results, boost::json::value const &request) -> boost::json::value {
validate_document(ratings, &catalog);
validate_document(results, &catalog);
require(text(ratings, "kind") == "ratings" && text(results, "kind") == "results", "Wrong snapshot kinds");
auto selected = std::map<std::string, std::string>{};
auto taken = std::set<std::string>{};
auto errors = boost::json::array{};
auto valid_slots = std::set<std::string>{};
for (auto const &office : offices) {
for (auto const &group : affiliations) {
valid_slots.insert(office + ":" + group);
}
}
for (auto const &selection : array(request, "roster")) {
auto slot = text(selection, "slot");
auto id = text(selection, "candidate_id");
if (!valid_slots.contains(slot) || !catalog.candidates.contains(id)) {
errors.emplace_back("Unknown slot or candidate: " + slot + " / " + id);
continue;
}
auto const &candidate = catalog.candidates.at(id);
auto const &race = catalog.races.at(candidate.race_id);
if (slot != race.office + ":flex" && slot != race.office + ":" + candidate.affiliation) {
errors.emplace_back("Candidate does not qualify for slot: " + slot);
}
if (!selected.emplace(slot, id).second || !taken.insert(id).second) {
errors.emplace_back("Duplicate slot or candidate: " + slot + " / " + id);
}
}
auto output = boost::json::object{{"catalog_id", catalog.id}, {"ratings_id", text(ratings, "id")}, {"results_id", text(results, "id")}, {"as_of", text(results, "as_of")}, {"valid", errors.empty()}, {"complete_roster", selected.size() == 12 && errors.empty()}, {"errors", errors}};
if (!errors.empty()) {
output["scores"] = boost::json::array{};
output["total"] = nullptr;
return output;
}
auto ratings_by_race = std::map<std::string, std::int64_t>{};
for (auto const &rating : array(ratings, "entries")) {
ratings_by_race.emplace(text(rating, "race_id"), multipliers.at(text(rating, "rating")));
}
auto results_by_race = std::map<std::string, boost::json::value const *>{};
for (auto const &result : array(results, "entries")) {
results_by_race.emplace(text(result, "race_id"), &result);
}
auto scores = boost::json::array{};
auto total_cents = std::int64_t{};
auto baseline_cents = std::int64_t{};
auto preview_cents = std::int64_t{};
auto missing = std::size_t{};
auto unavailable_previews = std::size_t{};
for (auto const &[slot, id] : selected) {
auto const &candidate = catalog.candidates.at(id);
auto row = boost::json::object{{"slot", slot}, {"candidate_id", id}, {"points", nullptr}};
auto reason = std::string{};
if (!ratings_by_race.contains(candidate.race_id)) {
reason = "Missing frozen rating";
} else if (!results_by_race.contains(candidate.race_id)) {
reason = "No result snapshot for race";
} else {
auto const &result = *results_by_race.at(candidate.race_id);
auto total = result.at("total_votes").is_null() ? 0 : integer(result, "total_votes");
auto votes = std::map<std::string, std::int64_t>{};
for (auto const &vote : array(result, "votes")) {
votes.emplace(text(vote, "candidate_id"), integer(vote, "votes"));
}
if (text(result, "status") == "pending_round") {
reason = "Pending runoff or ranked-choice resolution";
} else if (total == 0 || !votes.contains(id)) {
reason = "Vote share unavailable";
} else {
auto maximum = std::int64_t{-1};
auto leader = std::string{};
auto tied = false;
auto all_candidates_present = true;
auto accounted_votes = std::int64_t{};
for (auto const &[candidate_id, catalog_candidate] : catalog.candidates) {
if (catalog_candidate.race_id == candidate.race_id && !votes.contains(candidate_id)) {
all_candidates_present = false;
}
}
for (auto const &[candidate_id, count] : votes) {
accounted_votes += count;
if (count > maximum) {
maximum = count;
leader = candidate_id;
tied = false;
} else if (count == maximum) {
tied = true;
}
}
auto called = !result.at("winner_id").is_null();
auto won = called && text(result, "winner_id") == id;
auto preview_available = called || (!tied && all_candidates_present && accounted_votes == total);
auto multiplier = ratings_by_race.at(candidate.race_id);
auto actual = cents(votes.at(id), total, won, multiplier);
auto baseline = cents(votes.at(id), total, won, 100);
auto preview = cents(votes.at(id), total, won || (!called && !tied && all_candidates_present && leader == id), multiplier);
total_cents += actual;
baseline_cents += baseline;
if (preview_available) {
preview_cents += preview;
} else {
++unavailable_previews;
}
row["vote_share"] = static_cast<double>(votes.at(id)) * 100.0 / static_cast<double>(total);
row["multiplier"] = money(multiplier);
row["win_bonus"] = won ? 50 : 0;
row["points"] = money(actual);
row["baseline_points"] = money(baseline);
row["leader_preview_points"] = preview_available ? boost::json::value{money(preview)} : boost::json::value{};
row["status"] = text(result, "status");
row["leader_preview_available"] = preview_available;
}
}
if (!reason.empty()) {
++missing;
++unavailable_previews;
row["reason"] = reason;
}
scores.emplace_back(row);
}
output["scores"] = scores;
output["missing_scores"] = missing;
output["known_subtotal"] = money(total_cents);
output["baseline_subtotal"] = money(baseline_cents);
output["leader_preview_subtotal"] = money(preview_cents);
output["unavailable_leader_previews"] = unavailable_previews;
output["total"] = missing == 0 && selected.size() == 12 ? boost::json::value{money(total_cents)} : boost::json::value{};
return output;
}
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <cstdint>
#include <map>
#include <string>
#include <vector>
#include <boost/json.hpp>
namespace fpol {
struct race {
std::string id;
std::string office;
std::string state;
std::string round;
};
struct candidate {
std::string id;
std::string race_id;
std::string affiliation;
};
struct catalog {
std::string id;
std::string mode;
bool complete{};
std::map<std::string, fpol::race> races;
std::map<std::string, fpol::candidate> candidates;
};
auto text(boost::json::value const &value, std::string const &key) -> std::string;
auto integer(boost::json::value const &value, std::string const &key) -> std::int64_t;
auto read_catalog(boost::json::value const &document) -> fpol::catalog;
auto validate_document(boost::json::value const &document, fpol::catalog const *catalog = nullptr) -> void;
auto rules() -> boost::json::value;
auto capacity(fpol::catalog const &catalog, std::int64_t players) -> boost::json::value;
auto score_preview(fpol::catalog const &catalog, boost::json::value const &ratings, boost::json::value const &results, boost::json::value const &request) -> boost::json::value;
}
+462
View File
@@ -0,0 +1,462 @@
#include "application.hpp"
#include "crypto.hpp"
#include "results.hpp"
#include "national_races.hpp"
#include "domain.hpp"
#include <algorithm>
#include <array>
#include <map>
#include <set>
namespace {
auto string(boost::json::object const &value, std::string const &key) -> std::string {
return std::string{value.at(key).as_string()};
}
auto event(fpol::database &database, std::string const &league, std::string const &kind, boost::json::value body, std::int64_t now) -> void {
database.query("INSERT INTO events(league_id,kind,body,created_at) VALUES(?,?,?,?)", {league, kind, boost::json::serialize(body), now});
database.query("UPDATE leagues SET revision=revision+1 WHERE id=?", {league});
}
auto member(fpol::database &database, std::string const &league, std::string const &user) -> boost::json::object {
auto team = database.one("SELECT * FROM teams WHERE league_id=? AND user_id=?", {league, user});
fpol::ensure(!team.empty(), "This league is private", 403);
return team;
}
auto league_row(fpol::database &database, std::string const &id) -> boost::json::object {
auto league = database.one("SELECT * FROM leagues WHERE id=?", {id});
fpol::ensure(!league.empty(), "League not found", 404);
return league;
}
auto legal_candidates(fpol::catalog const &catalog, boost::json::array const &teams, boost::json::array const &picks, std::string const &team) -> std::map<std::string, std::string> {
auto taken = std::set<std::string>{};
auto filled = std::map<std::string, std::set<std::string>>{};
for (auto const &pick : picks) {
taken.insert(fpol::text(pick, "candidate_id"));
filled[fpol::text(pick, "team_id")].insert(fpol::text(pick, "slot"));
}
auto pool = std::map<std::string, std::map<std::string, int>>{};
auto needs = std::map<std::string, std::map<std::string, int>>{};
for (auto const &[id, candidate] : catalog.candidates) {
if (!taken.contains(id)) {
auto office = catalog.races.at(candidate.race_id).office;
++pool[office][candidate.affiliation];
++pool[office]["total"];
}
}
for (auto const &entry : teams) {
for (auto const &office : {"house", "senate", "governor"}) {
for (auto const &group : {"democrat", "republican", "other", "flex"}) {
if (filled[fpol::text(entry, "id")].contains(std::string{office} + ":" + group)) {
continue;
}
if (std::string_view{group} != "flex") {
++needs[office][group];
}
++needs[office]["total"];
}
}
}
auto result = std::map<std::string, std::string>{};
for (auto const &[id, candidate] : catalog.candidates) {
if (taken.contains(id)) {
continue;
}
auto office = catalog.races.at(candidate.race_id).office;
auto group = candidate.affiliation;
auto slot = office + ":" + group;
if (filled[team].contains(slot)) {
group = "flex";
slot = office + ":flex";
}
if (filled[team].contains(slot)) {
continue;
}
auto available = pool[office];
auto needed = needs[office];
--available[candidate.affiliation];
--available["total"];
--needed["total"];
if (group != "flex") {
--needed[group];
}
auto legal = true;
for (auto const &[affiliation, count] : needed) {
legal = legal && available[affiliation] >= count;
}
if (legal) {
result.emplace(id, slot);
}
}
return result;
}
auto current_team(boost::json::array const &teams, std::int64_t pick) -> boost::json::object {
auto round = pick / static_cast<std::int64_t>(teams.size());
auto position = pick % static_cast<std::int64_t>(teams.size());
if (round % 2 == 1) {
position = static_cast<std::int64_t>(teams.size()) - 1 - position;
}
return teams.at(static_cast<std::size_t>(position)).as_object();
}
auto make_pick(fpol::database &database, boost::json::object const &league, std::string candidate_id, bool automatic, std::int64_t now) -> boost::json::value {
auto id = string(league, "id");
auto draft = database.one("SELECT * FROM drafts WHERE league_id=?", {id});
auto teams = database.query("SELECT * FROM teams WHERE league_id=? ORDER BY draft_order", {id});
auto picks = database.query("SELECT * FROM picks WHERE league_id=? ORDER BY number", {id});
auto catalog = fpol::read_catalog(database.document(string(league, "catalog_id"), "catalog"));
auto team = current_team(teams, draft.at("pick_number").as_int64());
auto legal = legal_candidates(catalog, teams, picks, string(team, "id"));
fpol::ensure(!legal.empty(), "No legal picks remain; commissioner review required", 409);
if (automatic) {
auto queue = boost::json::parse(string(team, "queue")).as_array();
for (auto const &entry : queue) {
if (entry.is_string() && legal.contains(std::string{entry.as_string()})) {
candidate_id = std::string{entry.as_string()};
break;
}
}
if (candidate_id.empty()) {
auto iterator = legal.begin();
std::advance(iterator, static_cast<long>(fpol::random_index(legal.size())));
candidate_id = iterator->first;
}
}
fpol::ensure(legal.contains(candidate_id), "Candidate is unavailable or would prevent a complete roster", 409);
auto number = draft.at("pick_number").as_int64();
database.query("INSERT INTO picks(league_id,number,team_id,candidate_id,slot,automatic,created_at) VALUES(?,?,?,?,?,?,?)", {id, number, string(team, "id"), candidate_id, legal.at(candidate_id), automatic ? 1 : 0, now});
auto complete = number + 1 == static_cast<std::int64_t>(teams.size() * 12);
database.query("UPDATE drafts SET pick_number=pick_number+1,deadline=?,remaining=120 WHERE league_id=?", {complete ? noto::sqlite_value{nullptr} : noto::sqlite_value{now + 120}, id});
if (complete) {
database.query("UPDATE leagues SET status='drafted' WHERE id=?", {id});
}
auto result = boost::json::object{{"number", number}, {"team_id", string(team, "id")}, {"candidate_id", candidate_id}, {"slot", legal.at(candidate_id)}, {"automatic", automatic}};
event(database, id, "pick", result, now);
return result;
}
}
auto fpol::current_catalog(fpol::database &database, bool demo) -> boost::json::value {
auto rows = database.query("SELECT body FROM documents WHERE kind='catalog' ORDER BY as_of DESC,id DESC");
for (auto const &row : rows) {
auto doc = boost::json::parse(fpol::text(row, "body"));
if (fpol::text(doc, "mode") == (demo ? "demo" : "real")) {
return doc;
}
}
return nullptr;
}
auto fpol::readiness(fpol::database &database, boost::json::object const &league, std::size_t players, bool allow_demo) -> boost::json::value {
auto problems = boost::json::array{};
if (players < 2) {
problems.emplace_back("Invite at least one friend to draft");
}
if (league.at("catalog_id").is_null()) {
problems.emplace_back("A real candidate catalog is needed");
return boost::json::object{{"ready", false}, {"problems", problems}};
}
auto document = database.document(string(league, "catalog_id"), "catalog");
auto catalog = fpol::read_catalog(document);
if (catalog.mode == "demo") {
if (!allow_demo) {
problems.emplace_back("Demo leagues are disabled");
}
} else {
auto complete = catalog.complete && catalog.races.size() == fpol::national_races.size();
auto represented = std::set<std::string>{};
for (auto const &[id, candidate] : catalog.candidates) {
represented.insert(candidate.race_id);
}
for (auto const &id : fpol::national_races) {
complete = complete && catalog.races.contains(std::string{id}) && represented.contains(std::string{id});
}
if (!complete) {
problems.emplace_back("National candidate coverage is incomplete");
}
}
auto capacity = fpol::capacity(catalog, static_cast<std::int64_t>(std::max<std::size_t>(2, players)));
if (!capacity.at("sufficient_candidates").as_bool()) {
problems.emplace_back("Not enough eligible candidates for every roster slot");
}
if (league.at("ratings_id").is_null()) {
problems.emplace_back("Frozen race ratings are needed");
} else {
auto ratings = database.document(string(league, "ratings_id"), "ratings");
fpol::validate_document(ratings, &catalog);
if (ratings.at("entries").as_array().size() != catalog.races.size()) {
problems.emplace_back("Some races have no rating");
}
}
auto uncovered = 0;
if (catalog.mode == "real") {
for (auto const &[id, race] : catalog.races) {
auto coverage = database.one("SELECT c.race_id FROM coverage c JOIN sources a ON a.id=c.counts_source JOIN sources b ON b.id=c.outcome_source WHERE c.race_id=? AND a.enabled=1 AND b.enabled=1 AND a.verified=1 AND b.verified=1", {id});
if (coverage.empty()) {
++uncovered;
}
}
}
if (uncovered) {
problems.emplace_back(std::to_string(uncovered) + " races still need verified counts and outcome coverage");
}
return boost::json::object{{"ready", problems.empty()}, {"problems", problems}, {"capacity", capacity}, {"uncovered_races", uncovered}};
}
auto fpol::league_view(fpol::database &database, std::string const &id, std::string const &user_id, std::int64_t now, bool allow_demo) -> boost::json::value {
auto mine = member(database, id, user_id);
auto league = league_row(database, id);
league["catalog_mode"] = league.at("catalog_id").is_null() ? boost::json::value{} : database.document(string(league, "catalog_id"), "catalog").at("mode");
auto teams = database.query("SELECT t.id,t.name,t.user_id,t.draft_order,u.username FROM teams t JOIN users u ON u.id=t.user_id WHERE league_id=? ORDER BY draft_order,t.id", {id});
auto picks = database.query("SELECT number,team_id,candidate_id,slot,automatic,created_at FROM picks WHERE league_id=? ORDER BY number", {id});
auto draft = database.one("SELECT * FROM drafts WHERE league_id=?", {id});
auto legal = boost::json::array{};
auto picker = boost::json::value{};
if (!draft.empty() && string(league, "status") != "drafted") {
auto team = current_team(teams, draft.at("pick_number").as_int64());
picker = team;
if (string(team, "id") == string(mine, "id")) {
auto catalog = fpol::read_catalog(database.document(string(league, "catalog_id"), "catalog"));
for (auto const &[candidate, slot] : legal_candidates(catalog, teams, picks, string(mine, "id"))) {
legal.emplace_back(candidate);
}
}
}
auto names = boost::json::object{};
if (!league.at("catalog_id").is_null()) {
auto document = database.document(string(league, "catalog_id"), "catalog");
auto wanted = std::set<std::string>{};
for (auto const &pick : picks) {
wanted.insert(fpol::text(pick, "candidate_id"));
}
auto private_queue = boost::json::parse(string(mine, "queue"));
for (auto const &entry : private_queue.as_array()) {
wanted.insert(std::string{entry.as_string()});
}
for (auto const &candidate : document.at("candidates").as_array()) {
if (wanted.contains(fpol::text(candidate, "id"))) {
names[fpol::text(candidate, "id")] = candidate.at("name");
}
}
}
return boost::json::object{{"league", league}, {"teams", teams}, {"my_team", boost::json::object{{"id", string(mine, "id")}, {"name", string(mine, "name")}, {"queue", boost::json::parse(string(mine, "queue"))}}}, {"candidate_names", names}, {"picks", picks}, {"draft", draft}, {"picker", picker}, {"legal_candidate_ids", legal}, {"server_time", now}, {"readiness", fpol::readiness(database, league, teams.size(), allow_demo)}, {"events", database.query("SELECT id,kind,body,created_at FROM events WHERE league_id=? ORDER BY id DESC LIMIT 20", {id})}};
}
auto fpol::advance_drafts(fpol::database &database, std::int64_t now, bool restart) -> void {
auto leagues = database.query("SELECT l.* FROM leagues l JOIN drafts d ON d.league_id=l.id WHERE l.status='drafting' AND (?=1 OR d.deadline<=?)", {restart ? 1 : 0, now});
for (auto const &value : leagues) {
auto league = value.as_object();
auto id = string(league, "id");
if (restart) {
database.query("UPDATE leagues SET status='paused' WHERE id=?", {id});
database.query("UPDATE drafts SET deadline=NULL,remaining=120 WHERE league_id=?", {id});
event(database, id, "restart_pause", boost::json::object{}, now);
} else {
try {
make_pick(database, league, {}, true, now);
} catch (fpol::api_error const &error) {
if (error.status != 409) {
throw;
}
database.query("UPDATE leagues SET status='paused' WHERE id=?", {id});
database.query("UPDATE drafts SET deadline=NULL,remaining=120 WHERE league_id=?", {id});
event(database, id, "needs_review", boost::json::object{{"message", error.what()}}, now);
}
}
}
}
auto fpol::game_action(fpol::database &database, std::string const &action, boost::json::value const &input, boost::json::object const &user, std::int64_t now, bool allow_demo) -> boost::json::value {
auto user_id = string(user, "id");
if (action == "leagues") {
return database.query("SELECT l.*,(SELECT json_extract(d.body,'$.mode') FROM documents d WHERE d.id=l.catalog_id) AS catalog_mode,t.name AS team_name,(SELECT COUNT(*) FROM teams x WHERE x.league_id=l.id) AS members FROM leagues l JOIN teams t ON t.league_id=l.id WHERE t.user_id=? ORDER BY l.created_at DESC", {user_id});
}
if (action == "league/create") {
auto name = fpol::text(input, "name");
auto team_name = fpol::text(input, "team_name");
auto maximum = fpol::integer(input, "max_teams");
ensure(name.size() <= 80 && team_name.size() <= 60 && maximum >= 2 && maximum <= 12, "Invalid league name, team name or league size");
auto catalog = input.as_object().contains("catalog_id") && !input.at("catalog_id").is_null() ? database.document(fpol::text(input, "catalog_id"), "catalog") : current_catalog(database);
if (!catalog.is_null()) {
ensure(fpol::text(catalog, "mode") == "real" || allow_demo, "Demo leagues are disabled", 403);
}
auto ratings_id = std::string{};
if (!catalog.is_null()) {
auto ratings = database.one("SELECT id FROM documents WHERE kind='ratings' AND catalog_id=? ORDER BY as_of DESC,id DESC LIMIT 1", {fpol::text(catalog, "id")});
if (!ratings.empty()) {
ratings_id = string(ratings, "id");
}
}
auto id = fpol::random_token(12);
database.query("INSERT INTO leagues(id,name,commissioner_id,max_teams,catalog_id,ratings_id,created_at) VALUES(?,?,?,?,?,?,?)", {id, name, user_id, maximum, catalog.is_null() ? noto::sqlite_value{nullptr} : noto::sqlite_value{fpol::text(catalog, "id")}, ratings_id.empty() ? noto::sqlite_value{nullptr} : noto::sqlite_value{ratings_id}, now});
database.query("INSERT INTO teams(id,league_id,user_id,name) VALUES(?,?,?,?)", {fpol::random_token(12), id, user_id, team_name});
return boost::json::object{{"league_id", id}};
}
if (action == "league/join") {
auto invite = database.one("SELECT l.* FROM invites i JOIN leagues l ON l.id=i.league_id WHERE i.token_hash=? AND i.expires_at>?", {fpol::digest(fpol::text(input, "invite")), now});
ensure(!invite.empty(), "Invitation is invalid or expired", 404);
auto id = string(invite, "id");
auto existing = database.one("SELECT id FROM teams WHERE league_id=? AND user_id=?", {id, user_id});
if (!existing.empty()) {
return boost::json::object{{"league_id", id}};
}
ensure(string(invite, "status") == "forming", "The draft has already started", 409);
auto count = database.one("SELECT COUNT(*) AS n FROM teams WHERE league_id=?", {id}).at("n").as_int64();
ensure(count < invite.at("max_teams").as_int64(), "League is full", 409);
auto name = fpol::text(input, "team_name");
ensure(name.size() <= 60, "Team name is too long");
database.query("INSERT INTO teams(id,league_id,user_id,name) VALUES(?,?,?,?)", {fpol::random_token(12), id, user_id, name});
event(database, id, "joined", boost::json::object{{"username", user.at("username")}}, now);
return boost::json::object{{"league_id", id}};
}
auto id = fpol::text(input, "league_id");
auto mine = member(database, id, user_id);
auto league = league_row(database, id);
if (action == "standings") {
return fpol::standings(database, league);
}
if (action == "league") {
return league_view(database, id, user_id, now, allow_demo);
}
if (action == "team/name") {
auto name = fpol::text(input, "name");
ensure(name.size() <= 60, "Team name is too long");
database.query("UPDATE teams SET name=? WHERE id=?", {name, string(mine, "id")});
event(database, id, "team_renamed", boost::json::object{{"team_id", string(mine, "id")}}, now);
return boost::json::object{};
}
if (action == "draft/queue") {
ensure(string(league, "status") != "drafted", "Draft is complete", 409);
ensure(input.at("candidates").is_array() && input.at("candidates").as_array().size() <= 200, "Queue must contain at most 200 candidates");
ensure(!league.at("catalog_id").is_null(), "No catalog available", 409);
auto catalog = fpol::read_catalog(database.document(string(league, "catalog_id"), "catalog"));
auto seen = std::set<std::string>{};
for (auto const &candidate : input.at("candidates").as_array()) {
ensure(candidate.is_string(), "Invalid queued candidate");
auto value = std::string{candidate.as_string()};
ensure(catalog.candidates.contains(value) && seen.insert(value).second, "Unknown or duplicate queued candidate");
}
database.query("UPDATE teams SET queue=? WHERE id=?", {boost::json::serialize(input.at("candidates")), string(mine, "id")});
return boost::json::object{};
}
if (action == "draft/pick") {
auto request_id = fpol::text(input, "request_id");
ensure(request_id.size() <= 120, "Invalid request ID");
auto request_hash = fpol::digest(boost::json::serialize(input));
auto prior = database.one("SELECT request_hash,response FROM commands WHERE league_id=? AND user_id=? AND request_id=?", {id, user_id, request_id});
if (!prior.empty()) {
ensure(string(prior, "request_hash") == request_hash, "Request ID already used for a different pick", 409);
return boost::json::parse(string(prior, "response"));
}
ensure(string(league, "status") == "drafting", "Draft is not running", 409);
auto draft = database.one("SELECT * FROM drafts WHERE league_id=?", {id});
ensure(draft.at("deadline").as_int64() > now, "The turn has expired", 409);
ensure(fpol::integer(input, "pick_number") == draft.at("pick_number").as_int64(), "Draft has advanced; refresh the current turn", 409);
auto teams = database.query("SELECT * FROM teams WHERE league_id=? ORDER BY draft_order", {id});
ensure(string(current_team(teams, draft.at("pick_number").as_int64()), "user_id") == user_id, "It is another team's turn", 403);
auto result = make_pick(database, league, fpol::text(input, "candidate_id"), false, now);
database.query("INSERT INTO commands VALUES(?,?,?,?,?)", {id, user_id, request_id, request_hash, boost::json::serialize(result)});
return result;
}
ensure(string(league, "commissioner_id") == user_id, "Commissioner access required", 403);
if (action == "league/name") {
auto name = fpol::text(input, "name");
ensure(name.size() <= 80, "League name is too long");
database.query("UPDATE leagues SET name=? WHERE id=?", {name, id});
event(database, id, "league_renamed", boost::json::object{}, now);
return boost::json::object{};
}
if (action == "league/ratings") {
ensure(string(league, "status") == "forming" && !league.at("catalog_id").is_null(), "Choose a catalog before importing ratings; imports close at draft start", 409);
auto catalog = fpol::read_catalog(database.document(string(league, "catalog_id"), "catalog"));
auto document = input.at("document");
ensure(fpol::text(document, "kind") == "ratings", "Expected a ratings snapshot");
fpol::validate_document(document, &catalog);
auto rating_id = fpol::text(document, "id");
auto body = boost::json::serialize(document);
auto previous = database.one("SELECT body FROM documents WHERE id=?", {rating_id});
ensure(previous.empty() || string(previous, "body") == body, "This immutable snapshot ID already exists with different contents", 409);
database.query("INSERT OR IGNORE INTO documents(id,kind,catalog_id,as_of,body) VALUES(?,'ratings',?,?,?)", {rating_id, catalog.id, fpol::text(document, "as_of"), body});
database.query("UPDATE leagues SET ratings_id=? WHERE id=?", {rating_id, id});
event(database, id, "ratings_imported", boost::json::object{{"ratings_id", rating_id}}, now);
return boost::json::object{{"ratings_id", rating_id}};
}
if (action == "league/invite") {
ensure(string(league, "status") == "forming", "Invitations close when the draft starts", 409);
auto token = fpol::random_token();
database.query("DELETE FROM invites WHERE league_id=?", {id});
database.query("INSERT INTO invites VALUES(?,?,?)", {fpol::digest(token), id, now + 7 * 86400});
return boost::json::object{{"invite", token}, {"expires_at", now + 7 * 86400}};
}
if (action == "league/revoke-invites") {
database.query("DELETE FROM invites WHERE league_id=?", {id});
return boost::json::object{};
}
if (action == "league/remove") {
ensure(string(league, "status") == "forming", "Membership is frozen after draft start", 409);
auto team_id = fpol::text(input, "team_id");
ensure(team_id != string(mine, "id"), "The commissioner cannot remove their own team");
database.query("DELETE FROM teams WHERE id=? AND league_id=?", {team_id, id});
database.query("DELETE FROM invites WHERE league_id=?", {id});
event(database, id, "member_removed", boost::json::object{{"team_id", team_id}}, now);
return boost::json::object{};
}
if (action == "league/settings") {
ensure(string(league, "status") == "forming", "Settings are frozen after draft start", 409);
auto name = fpol::text(input, "name");
ensure(name.size() <= 80, "League name is too long");
auto catalog = database.document(fpol::text(input, "catalog_id"), "catalog");
auto model = fpol::read_catalog(catalog);
ensure(model.mode == "real" || allow_demo, "Demo leagues are disabled", 403);
auto rating_id = std::string{};
if (input.as_object().contains("ratings_id") && !input.at("ratings_id").is_null()) {
rating_id = fpol::text(input, "ratings_id");
fpol::validate_document(database.document(rating_id, "ratings"), &model);
}
database.query("UPDATE leagues SET name=?,catalog_id=?,ratings_id=? WHERE id=?", {name, model.id, rating_id.empty() ? noto::sqlite_value{nullptr} : noto::sqlite_value{rating_id}, id});
database.query("UPDATE teams SET queue='[]' WHERE league_id=?", {id});
event(database, id, "settings_changed", boost::json::object{}, now);
return boost::json::object{};
}
if (action == "draft/start") {
ensure(string(league, "status") == "forming", "Draft already started", 409);
auto teams = database.query("SELECT * FROM teams WHERE league_id=? ORDER BY id", {id});
auto ready = fpol::readiness(database, league, teams.size(), allow_demo);
ensure(ready.at("ready").as_bool(), "League data or membership is not ready: " + boost::json::serialize(ready.at("problems")), 409);
for (std::size_t index = teams.size(); index > 1; --index) {
std::swap(teams[index - 1], teams[fpol::random_index(index)]);
}
for (std::size_t index = 0; index < teams.size(); ++index) {
database.query("UPDATE teams SET draft_order=? WHERE id=?", {static_cast<std::int64_t>(index), fpol::text(teams[index], "id")});
}
database.query("INSERT INTO drafts(league_id,deadline) VALUES(?,?)", {id, now + 120});
auto frozen_rules = fpol::rules();
frozen_rules.as_object()["version"] = "v2-november-share";
frozen_rules.as_object()["vote_share_round"] = "november_first";
database.query("UPDATE leagues SET status='drafting',rules_json=? WHERE id=?", {boost::json::serialize(frozen_rules), id});
database.query("DELETE FROM invites WHERE league_id=?", {id});
event(database, id, "draft_started", boost::json::object{}, now);
return boost::json::object{};
}
if (action == "draft/pause") {
ensure(string(league, "status") == "drafting", "Draft is not running", 409);
database.query("UPDATE drafts SET remaining=MAX(0,deadline-?),deadline=NULL WHERE league_id=?", {now, id});
database.query("UPDATE leagues SET status='paused' WHERE id=?", {id});
event(database, id, "paused", boost::json::object{}, now);
return boost::json::object{};
}
if (action == "draft/resume") {
ensure(string(league, "status") == "paused", "Draft is not paused", 409);
database.query("UPDATE drafts SET deadline=?+remaining WHERE league_id=?", {now, id});
database.query("UPDATE leagues SET status='drafting' WHERE id=?", {id});
event(database, id, "resumed", boost::json::object{}, now);
return boost::json::object{};
}
throw fpol::api_error{404, "Unknown action"};
}
+106
View File
@@ -0,0 +1,106 @@
#include "ingestion.hpp"
#include "store.hpp"
#include "domain.hpp"
#include <csignal>
#include <iostream>
#include <fstream>
#include <thread>
namespace {
volatile std::sig_atomic_t stopped = 0;
void stop(int) {
stopped = 1;
}
auto execute(fpol::store &store, std::filesystem::path path, std::vector<std::string> args) -> boost::asio::awaitable<void> {
co_await store.initialize();
auto action = args.at(0);
if (action == "probe") {
fpol::ensure(args.size() == 2, "probe SOURCE_ID");
co_await fpol::poll_source(path, args[1]);
co_return;
}
if (action == "once" || action == "run") {
do {
auto ids = boost::json::array{};
{
auto database = fpol::database{path};
ids = database.query("SELECT id FROM sources WHERE enabled=1 AND next_check<=strftime('%s','now')");
}
for (auto const &row : ids) {
if (stopped) {
break;
}
co_await fpol::poll_source(path, fpol::text(row, "id"));
}
if (action == "run" && !stopped) {
std::this_thread::sleep_for(std::chrono::seconds{1});
}
} while (action == "run" && !stopped);
co_return;
}
auto database = fpol::database{path};
database.begin();
auto result = boost::json::value{};
if (action == "configure" && args.size() == 2) {
result = fpol::configure_source(database, fpol::read_json(args[1]));
} else if (action == "activate" && args.size() == 2) {
result = fpol::activate_source(database, args[1]);
} else if (action == "watch" && args.size() == 2) {
auto source = database.one("SELECT kind FROM sources WHERE id=?", {args[1]});
fpol::ensure(!source.empty() && fpol::text(source, "kind") == "candidate-watch", "Only candidate-watch sources may be watched without result verification");
database.query("UPDATE sources SET enabled=1 WHERE id=?", {args[1]});
result = boost::json::object{{"watching", args[1]}};
} else if (action == "disable" && args.size() == 2) {
database.query("UPDATE sources SET enabled=0 WHERE id=?", {args[1]});
result = boost::json::object{{"disabled", args[1]}};
} else if (action == "replay" && args.size() == 3) {
auto file = std::ifstream{args[2], std::ios::binary};
fpol::ensure(static_cast<bool>(file), "Cannot read replay file");
auto body = std::string{std::istreambuf_iterator<char>{file}, {}};
result = fpol::accept_payload(database, args[1], body, std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count(), false);
} else if (action == "status" && args.size() == 1) {
result = database.query("SELECT id,kind,enabled,verified,last_checked,last_success,last_changed,last_error FROM sources ORDER BY id");
} else {
throw std::invalid_argument{"Unknown command or wrong arguments; use --help"};
}
database.commit();
std::cout << boost::json::serialize(result) << '\n';
}
}
int main(int argc, char **argv) {
if (argc == 2 && std::string_view{argv[1]} == "--help") {
std::cout << "fpol_ingest [--database PATH] configure FILE | probe ID | activate ID | watch ID | disable ID | replay ID FILE | status | once | run\nReplays never verify a source or publish to real catalogs.\n";
return 0;
}
try {
auto index = 1;
auto path = std::filesystem::path{"var/fpol.sqlite"};
if (index < argc && std::string_view{argv[index]} == "--database") {
fpol::ensure(index + 1 < argc, "Missing database");
path = argv[++index];
++index;
}
auto args = std::vector<std::string>{};
while (index < argc) {
args.emplace_back(argv[index++]);
}
fpol::ensure(!args.empty(), "Missing command");
std::signal(SIGINT, stop);
std::signal(SIGTERM, stop);
auto io = boost::asio::io_context{};
auto store = fpol::store{io.get_executor(), path};
auto future = boost::asio::co_spawn(io, execute(store, path, args), boost::asio::use_future);
io.run();
future.get();
return 0;
} catch (std::exception const &error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+284
View File
@@ -0,0 +1,284 @@
#include "ingestion.hpp"
#include "domain.hpp"
#include "crypto.hpp"
#include <chrono>
#include <regex>
#include <set>
#include <ligo.hpp>
namespace {
auto now() -> std::int64_t {
return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
}
auto source_time(boost::json::value const &value) -> boost::json::value {
if (value.is_null()) {
return nullptr;
}
fpol::ensure(value.is_string(), "Invalid source timestamp");
auto text = std::string{value.as_string()};
if (text.ends_with("+00:00")) {
text = text.substr(0, text.size() - 6) + "Z";
}
fpol::ensure(std::regex_match(text, std::regex{"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z"}), "Source timestamp needs an explicit UTC normalization");
auto date = std::chrono::year_month_day{std::chrono::year{std::stoi(text.substr(0, 4))}, std::chrono::month{static_cast<unsigned>(std::stoi(text.substr(5, 2)))}, std::chrono::day{static_cast<unsigned>(std::stoi(text.substr(8, 2)))}};
fpol::ensure(date.ok() && std::stoi(text.substr(11, 2)) < 24 && std::stoi(text.substr(14, 2)) < 60 && std::stoi(text.substr(17, 2)) < 60, "Invalid source timestamp");
return boost::json::value{text};
}
auto normalize(boost::json::value const &config, boost::json::value const &payload, fpol::catalog const &catalog) -> boost::json::array {
auto entries = boost::json::array{};
auto kind = fpol::text(config, "kind");
if (kind == "openamerica") {
fpol::ensure(fpol::integer(payload, "api") == 1 && fpol::integer(payload, "year") == 2026, "Unexpected Open America API/election version");
fpol::ensure(fpol::text(payload, "office") == fpol::text(config, "office"), "Unexpected office");
fpol::ensure(payload.at("stale").is_bool() && !payload.at("stale").as_bool(), "Provider reports stale data");
auto time = source_time(payload.at("as_of"));
for (auto const &mapping : config.at("races").as_array()) {
auto key = fpol::text(mapping, "provider_id");
auto race = payload.at("races").as_object().if_contains(key);
if (!race) {
continue;
}
auto votes = boost::json::array{};
auto totals = std::map<std::string, std::int64_t>{};
auto seen = std::set<std::string>{};
auto const &candidates = mapping.at("candidates").as_object();
for (auto const &candidate : race->at("cands").as_array()) {
auto identity = fpol::text(candidate, "n") + "|" + std::string{candidate.at("p").as_string()};
fpol::ensure(seen.insert(identity).second, "Duplicate provider ballot line");
auto id = candidates.if_contains(identity);
fpol::ensure(id != nullptr, "Unmapped provider candidacy: " + identity);
if (id->is_null()) {
continue;
}
auto count = fpol::integer(candidate, "v");
fpol::ensure(count >= 0 && count <= 1000000000, "Invalid provider votes");
totals[std::string{id->as_string()}] += count;
}
for (auto const &[candidate, votes_cast] : totals) {
votes.emplace_back(boost::json::object{{"candidate_id", candidate}, {"votes", votes_cast}});
}
entries.emplace_back(boost::json::object{{"race_id", mapping.at("race_id")}, {"kind", "counts"}, {"round", "november_first"}, {"source_time", time}, {"total_votes", race->at("total")}, {"votes", votes}});
}
} else if (kind == "official-json") {
fpol::ensure(fpol::integer(payload, "schema_version") == 1, "Unexpected official JSON schema");
entries = payload.at("observations").as_array();
} else {
throw fpol::api_error{409, "This source is capture-only until its adapter contract is reviewed"};
}
auto selected = std::set<std::pair<std::string, std::string>>{};
for (auto const &entry : entries) {
auto id = fpol::text(entry, "race_id");
auto role = fpol::text(entry, "kind");
fpol::ensure(catalog.races.contains(id) && selected.emplace(id, role).second, "Unknown or duplicate race observation");
auto assigned = false;
for (auto const &race : config.at("races").as_array()) {
if (fpol::text(race, "race_id") == id && fpol::text(race, "role") == role) {
assigned = true;
}
}
fpol::ensure(assigned, "Observation role/race is outside reviewed source assignments");
source_time(entry.at("source_time"));
if (role == "counts") {
fpol::ensure(fpol::text(entry, "round") == "november_first", "Later-round counts must never replace November first-choice counts");
auto total = fpol::integer(entry, "total_votes");
fpol::ensure(total >= 0 && total <= 1000000000, "Invalid total votes");
auto sum = std::int64_t{};
auto seen = std::set<std::string>{};
for (auto const &vote : entry.at("votes").as_array()) {
auto candidate = fpol::text(vote, "candidate_id");
auto count = fpol::integer(vote, "votes");
fpol::ensure(catalog.candidates.contains(candidate) && catalog.candidates.at(candidate).race_id == id && seen.insert(candidate).second, "Unknown, duplicate or wrong-race candidacy");
fpol::ensure(count >= 0 && count <= total, "Invalid candidate votes");
sum += count;
}
fpol::ensure(sum <= total, "Candidate votes exceed total");
} else {
fpol::ensure(role == "outcome", "Invalid observation role");
auto status = fpol::text(entry, "status");
fpol::ensure(status == "reporting" || status == "pending_round" || status == "called" || status == "certified", "Invalid outcome status");
fpol::text(entry, "round");
auto winner = entry.at("winner_id");
fpol::ensure((status == "called" || status == "certified") == !winner.is_null(), "Winner and outcome status disagree");
if (!winner.is_null()) {
auto candidate = fpol::text(entry, "winner_id");
fpol::ensure(catalog.candidates.contains(candidate) && catalog.candidates.at(candidate).race_id == id, "Unknown outcome winner");
}
}
}
return entries;
}
}
auto fpol::configure_source(fpol::database &database, boost::json::value const &config) -> boost::json::value {
auto id = fpol::text(config, "id");
auto kind = fpol::text(config, "kind");
auto url = fpol::text(config, "url");
ensure(std::regex_match(id, std::regex{"[a-z0-9_-]{1,80}"}), "Invalid source ID");
ensure(std::regex_match(url, std::regex{"https://[a-zA-Z0-9.-]+(/[^ #]*)?"}), "Sources require a standard HTTPS host without credentials, ports or fragments");
ensure(kind == "openamerica" || kind == "official-json" || kind == "civicapi-probe" || kind == "candidate-watch", "Unsupported source adapter");
auto catalog = fpol::read_catalog(database.document(fpol::text(config, "catalog_id"), "catalog"));
auto assigned = std::set<std::pair<std::string, std::string>>{};
for (auto const &race : config.at("races").as_array()) {
auto race_id = fpol::text(race, "race_id");
auto role = fpol::text(race, "role");
ensure(catalog.races.contains(race_id) && (role == "counts" || role == "outcome") && assigned.emplace(race_id, role).second, "Invalid source assignment");
if (kind == "openamerica") {
ensure(role == "counts", "Open America does not provide race calls");
fpol::text(race, "provider_id");
for (auto const &entry : race.at("candidates").as_object()) {
if (!entry.value().is_null()) {
auto candidate = std::string{entry.value().as_string()};
ensure(catalog.candidates.contains(candidate) && catalog.candidates.at(candidate).race_id == race_id, "Invalid candidacy mapping");
}
}
}
}
if (kind == "openamerica") {
ensure(url.starts_with("https://openamerica.io/elections/results.json?"), "Unexpected Open America endpoint");
fpol::text(config, "office");
source_time(config.at("counts_until"));
ensure(!config.at("counts_until").is_null(), "Counts cutoff is required to avoid later-round overwrites");
}
auto serialized = boost::json::serialize(config);
auto previous = database.one("SELECT config FROM sources WHERE id=?", {id});
if (!previous.empty() && fpol::text(previous, "config") == serialized) {
return boost::json::object{{"id", id}, {"unchanged", true}};
}
database.query("DELETE FROM source_checks WHERE source_id=?", {id});
database.query("INSERT INTO sources(id,kind,url,config) VALUES(?,?,?,?) ON CONFLICT(id) DO UPDATE SET kind=excluded.kind,url=excluded.url,config=excluded.config,enabled=0,verified=0,last_success=NULL,etag=NULL,last_modified=NULL,last_payload=NULL,next_check=0", {id, kind, url, serialized});
return boost::json::object{{"id", id}, {"enabled", false}, {"verified", false}};
}
auto fpol::accept_payload(fpol::database &database, std::string const &id, std::string const &body, std::int64_t time, bool network) -> boost::json::value {
auto source = database.one("SELECT * FROM sources WHERE id=?", {id});
ensure(!source.empty(), "Unknown source", 404);
auto config = boost::json::parse(fpol::text(source, "config"));
auto payload_id = fpol::digest(id + body);
database.query("INSERT OR IGNORE INTO source_payloads VALUES(?,?,?,?)", {payload_id, id, time, body});
try {
auto kind = fpol::text(source, "kind");
if (kind == "candidate-watch" || kind == "civicapi-probe") {
database.query("UPDATE sources SET last_checked=?,last_success=?,last_changed=CASE WHEN last_payload IS NULL OR last_payload!=? THEN ? ELSE last_changed END,last_payload=?,last_error=?,next_check=? WHERE id=?", {time, time, payload_id, time, payload_id, kind == "candidate-watch" ? "Candidate source captured; review changes before import" : "Captured only: civicAPI schema/mappings still require validation", time + 86400, id});
return boost::json::object{{"captured", true}, {"verified", false}, {"payload_id", payload_id}};
}
if (kind == "openamerica") {
auto cutoff = std::string{source_time(config.at("counts_until")).as_string()};
auto date = std::chrono::year_month_day{std::chrono::year{std::stoi(cutoff.substr(0, 4))}, std::chrono::month{static_cast<unsigned>(std::stoi(cutoff.substr(5, 2)))}, std::chrono::day{static_cast<unsigned>(std::stoi(cutoff.substr(8, 2)))}};
auto seconds = std::chrono::duration_cast<std::chrono::seconds>(std::chrono::sys_days{date}.time_since_epoch()).count() + std::stoi(cutoff.substr(11, 2)) * 3600 + std::stoi(cutoff.substr(14, 2)) * 60 + std::stoi(cutoff.substr(17, 2));
ensure(time < seconds, "Automatic counts cutoff reached; use a reviewed first-round correction import");
}
auto catalog = fpol::read_catalog(database.document(fpol::text(config, "catalog_id"), "catalog"));
auto entries = normalize(config, boost::json::parse(body), catalog);
// Validate the complete response before writing any observation. A bad race quarantines this response.
auto changed = 0;
for (auto const &entry : entries) {
auto race = fpol::text(entry, "race_id");
auto role = fpol::text(entry, "kind");
auto normalized = entry.as_object();
normalized["source_time"] = source_time(entry.at("source_time"));
auto serialized = boost::json::serialize(normalized);
auto observation_id = fpol::digest(id + serialized);
database.query("INSERT OR IGNORE INTO observations VALUES(?,?,?,?,?,?,?)", {observation_id, id, race, role, normalized.at("source_time").is_null() ? noto::sqlite_value{nullptr} : noto::sqlite_value{fpol::text(normalized, "source_time")}, time, serialized});
if (network) {
database.query("INSERT INTO source_checks VALUES(?,?,?,?) ON CONFLICT(source_id,race_id,kind) DO UPDATE SET checked_at=excluded.checked_at", {id, race, role, time});
}
// Offline replays are fixtures only and cannot replace data in a real catalog.
if (!network && catalog.mode != "demo") {
continue;
}
auto previous = database.one("SELECT o.id,o.body,o.source_time,o.source_id FROM current_observations c JOIN observations o ON o.id=c.observation_id WHERE c.race_id=? AND c.kind=?", {race, role});
auto coverage = database.one("SELECT * FROM coverage WHERE race_id=?", {race});
auto column = role == "counts" ? "counts_source" : "outcome_source";
if (!network && catalog.mode == "demo" && (coverage.empty() || coverage.at(column).is_null())) {
database.query("INSERT INTO coverage(race_id," + std::string{column} + ") VALUES(?,?) ON CONFLICT(race_id) DO UPDATE SET " + column + "=excluded." + column, {race, id});
}
if (!coverage.empty() && !coverage.at(column).is_null() && fpol::text(coverage, column) != id) {
continue;
}
if (!previous.empty()) {
if (fpol::text(previous, "id") == observation_id) {
continue;
}
if (!previous.at("source_time").is_null() && (normalized.at("source_time").is_null() || fpol::text(normalized, "source_time") < fpol::text(previous, "source_time"))) {
continue;
}
auto old = boost::json::parse(fpol::text(previous, "body"));
if (role == "outcome" && fpol::text(old, "status") == "certified" && fpol::text(entry, "status") != "certified") {
continue;
}
}
database.query("INSERT INTO current_observations VALUES(?,?,?) ON CONFLICT(race_id,kind) DO UPDATE SET observation_id=excluded.observation_id", {race, role, observation_id});
++changed;
}
auto verified = network && !entries.empty();
database.query("UPDATE sources SET last_checked=?,last_success=?,last_changed=CASE WHEN ?>0 THEN ? ELSE last_changed END,last_error=NULL,failures=0,next_check=?,verified=CASE WHEN ? THEN 1 ELSE verified END WHERE id=?", {time, time, changed, time, time + 60, verified ? 1 : 0, id});
return boost::json::object{{"observations", entries.size()}, {"changed", changed}, {"verified", verified}, {"payload_id", payload_id}};
} catch (std::exception const &error) {
database.query("INSERT INTO source_quarantine(source_id,retrieved_at,reason,payload_id) VALUES(?,?,?,?)", {id, time, std::string{error.what()}, payload_id});
database.query("UPDATE sources SET last_checked=?,last_error=?,failures=failures+1,next_check=?+MIN(3600,60*(1 << MIN(failures,6))) WHERE id=?", {time, std::string{error.what()}, time, id});
return boost::json::object{{"quarantined", true}, {"message", error.what()}, {"payload_id", payload_id}};
}
}
auto fpol::activate_source(fpol::database &database, std::string const &id) -> boost::json::value {
auto source = database.one("SELECT * FROM sources WHERE id=?", {id});
ensure(!source.empty(), "Unknown source", 404);
ensure(source.at("verified").as_int64() == 1 && source.at("last_error").is_null(), "A successful live capture and mapping validation are required before activation", 409);
auto config = boost::json::parse(fpol::text(source, "config"));
for (auto const &race : config.at("races").as_array()) {
ensure(!database.one("SELECT race_id FROM source_checks WHERE source_id=? AND race_id=? AND kind=?", {id, fpol::text(race, "race_id"), fpol::text(race, "role")}).empty(), "Every assigned race/role needs its own validated live observation", 409);
auto column = fpol::text(race, "role") == "counts" ? "counts_source" : "outcome_source";
database.query("INSERT INTO coverage(race_id," + std::string{column} + ") VALUES(?,?) ON CONFLICT(race_id) DO UPDATE SET " + column + "=excluded." + column, {fpol::text(race, "race_id"), id});
database.query("DELETE FROM current_observations WHERE race_id=? AND kind=? AND observation_id IN (SELECT id FROM observations WHERE source_id!=?)", {fpol::text(race, "race_id"), fpol::text(race, "role"), id});
}
database.query("UPDATE sources SET enabled=1,etag=NULL,last_modified=NULL,next_check=0 WHERE id=?", {id});
return boost::json::object{{"id", id}, {"enabled", true}};
}
auto fpol::poll_source(std::filesystem::path path, std::string id) -> boost::asio::awaitable<void> {
auto source = boost::json::object{};
{
auto database = fpol::database{path};
source = database.one("SELECT * FROM sources WHERE id=?", {id});
}
ensure(!source.empty(), "Unknown source", 404);
auto url = fpol::text(source, "url");
auto match = std::smatch{};
ensure(std::regex_match(url, match, std::regex{"https://([a-zA-Z0-9.-]+)(/[^ ]*)?"}), "Only standard HTTPS endpoints are supported");
auto options = ligo::http::client_options{};
options.timeout = std::chrono::seconds{25};
options.security.mode = ligo::security_mode::TLS;
options.security.verify = ligo::verify_mode::PEER;
auto client = ligo::http::client{{match[1].str(), 443}, options};
auto request = ligo::http::request{};
request.method = ligo::http::method::GET;
request.target = match[2].matched ? match[2].str() : "/";
request.headers = {{"Accept", "application/json"}, {"Accept-Encoding", "identity"}, {"User-Agent", "product-fpol/0.1 (+private fantasy game)"}};
if (!source.at("etag").is_null()) {
request.headers["If-None-Match"] = fpol::text(source, "etag");
}
if (!source.at("last_modified").is_null()) {
request.headers["If-Modified-Since"] = fpol::text(source, "last_modified");
}
auto response = co_await client.exchange(request);
auto time = now();
auto database = fpol::database{path};
database.begin();
if (response && response->status == 304) {
database.query("UPDATE sources SET last_checked=?,last_success=?,last_error=NULL,failures=0,next_check=? WHERE id=?", {time, time, time + (fpol::text(source, "kind") == "candidate-watch" ? 86400 : 60), id});
} else if (response && response->status == 200) {
auto accepted = accept_payload(database, id, response->body, time, true);
if (!accepted.as_object().contains("quarantined")) {
database.query("UPDATE sources SET etag=?,last_modified=? WHERE id=?", {response->headers.contains("etag") ? noto::sqlite_value{response->headers.at("etag")} : noto::sqlite_value{nullptr}, response->headers.contains("last-modified") ? noto::sqlite_value{response->headers.at("last-modified")} : noto::sqlite_value{nullptr}, id});
}
} else {
auto message = response ? "HTTP " + std::to_string(response->status) : response.error().describe();
database.query("UPDATE sources SET last_checked=?,last_error=?,failures=failures+1,next_check=?+MIN(3600,60*(1 << MIN(failures,6))) WHERE id=?", {time, message, time, id});
}
database.commit();
}
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include "database.hpp"
namespace fpol {
auto configure_source(fpol::database &database, boost::json::value const &config) -> boost::json::value;
auto accept_payload(fpol::database &database, std::string const &source_id, std::string const &body, std::int64_t now, bool network) -> boost::json::value;
auto activate_source(fpol::database &database, std::string const &id) -> boost::json::value;
auto poll_source(std::filesystem::path path, std::string id) -> boost::asio::awaitable<void>;
}
+18 -3
View File
@@ -16,15 +16,27 @@ auto parse_options(int argc, char **argv) -> fpol::server_options {
auto options = fpol::server_options{};
for (auto index = 1; index < argc; ++index) {
auto name = std::string_view{argv[index]};
if (name != "--host" && name != "--port" && name != "--web-root") {
if (name == "--allow-demo") {
options.allow_demo = true;
continue;
}
if (name == "--secure-cookies") {
options.secure_cookies = true;
continue;
}
if (name != "--origin" && name != "--host" && name != "--port" && name != "--web-root" && name != "--database") {
throw std::invalid_argument("Unknown option: " + std::string{name});
}
if (++index == argc || std::string_view{argv[index]}.empty()) {
throw std::invalid_argument("Missing value for " + std::string{name});
}
auto value = std::string_view{argv[index]};
if (name == "--host") {
if (name == "--origin") {
options.origin = value;
} else if (name == "--host") {
options.host = value;
} else if (name == "--database") {
options.database = value;
} else if (name == "--web-root") {
options.web_root = value;
} else {
@@ -36,6 +48,9 @@ auto parse_options(int argc, char **argv) -> fpol::server_options {
options.port = static_cast<std::uint16_t>(port);
}
}
if (options.secure_cookies && !options.origin.starts_with("https://")) {
throw std::invalid_argument("Secure cookies require an HTTPS origin");
}
return options;
}
@@ -43,7 +58,7 @@ auto parse_options(int argc, char **argv) -> fpol::server_options {
auto main(int argc, char **argv) -> int {
if (argc == 2 && std::string_view{argv[1]} == "--help") {
std::cout << "Usage: fpol_server [--host ADDRESS] [--port PORT] [--web-root DIRECTORY]\n"
std::cout << "Usage: fpol_server [--host ADDRESS] [--port PORT] [--web-root DIRECTORY] [--database PATH] [--origin URL] [--secure-cookies] [--allow-demo]\n"
"Defaults: 127.0.0.1:8080, API only. Port 0 selects an available port.\n";
return 0;
}
+517
View File
@@ -0,0 +1,517 @@
#pragma once
#include <array>
#include <string_view>
namespace fpol {
inline constexpr auto national_races = std::array{
std::string_view{"2026-al-house-01-general"},
std::string_view{"2026-al-house-02-general"},
std::string_view{"2026-al-house-03-general"},
std::string_view{"2026-al-house-04-general"},
std::string_view{"2026-al-house-05-general"},
std::string_view{"2026-al-house-06-general"},
std::string_view{"2026-al-house-07-general"},
std::string_view{"2026-al-senate-general"},
std::string_view{"2026-al-governor-general"},
std::string_view{"2026-ak-house-01-general"},
std::string_view{"2026-ak-senate-general"},
std::string_view{"2026-ak-governor-general"},
std::string_view{"2026-az-house-01-general"},
std::string_view{"2026-az-house-02-general"},
std::string_view{"2026-az-house-03-general"},
std::string_view{"2026-az-house-04-general"},
std::string_view{"2026-az-house-05-general"},
std::string_view{"2026-az-house-06-general"},
std::string_view{"2026-az-house-07-general"},
std::string_view{"2026-az-house-08-general"},
std::string_view{"2026-az-house-09-general"},
std::string_view{"2026-az-governor-general"},
std::string_view{"2026-ar-house-01-general"},
std::string_view{"2026-ar-house-02-general"},
std::string_view{"2026-ar-house-03-general"},
std::string_view{"2026-ar-house-04-general"},
std::string_view{"2026-ar-senate-general"},
std::string_view{"2026-ar-governor-general"},
std::string_view{"2026-ca-house-01-general"},
std::string_view{"2026-ca-house-02-general"},
std::string_view{"2026-ca-house-03-general"},
std::string_view{"2026-ca-house-04-general"},
std::string_view{"2026-ca-house-05-general"},
std::string_view{"2026-ca-house-06-general"},
std::string_view{"2026-ca-house-07-general"},
std::string_view{"2026-ca-house-08-general"},
std::string_view{"2026-ca-house-09-general"},
std::string_view{"2026-ca-house-10-general"},
std::string_view{"2026-ca-house-11-general"},
std::string_view{"2026-ca-house-12-general"},
std::string_view{"2026-ca-house-13-general"},
std::string_view{"2026-ca-house-14-general"},
std::string_view{"2026-ca-house-15-general"},
std::string_view{"2026-ca-house-16-general"},
std::string_view{"2026-ca-house-17-general"},
std::string_view{"2026-ca-house-18-general"},
std::string_view{"2026-ca-house-19-general"},
std::string_view{"2026-ca-house-20-general"},
std::string_view{"2026-ca-house-21-general"},
std::string_view{"2026-ca-house-22-general"},
std::string_view{"2026-ca-house-23-general"},
std::string_view{"2026-ca-house-24-general"},
std::string_view{"2026-ca-house-25-general"},
std::string_view{"2026-ca-house-26-general"},
std::string_view{"2026-ca-house-27-general"},
std::string_view{"2026-ca-house-28-general"},
std::string_view{"2026-ca-house-29-general"},
std::string_view{"2026-ca-house-30-general"},
std::string_view{"2026-ca-house-31-general"},
std::string_view{"2026-ca-house-32-general"},
std::string_view{"2026-ca-house-33-general"},
std::string_view{"2026-ca-house-34-general"},
std::string_view{"2026-ca-house-35-general"},
std::string_view{"2026-ca-house-36-general"},
std::string_view{"2026-ca-house-37-general"},
std::string_view{"2026-ca-house-38-general"},
std::string_view{"2026-ca-house-39-general"},
std::string_view{"2026-ca-house-40-general"},
std::string_view{"2026-ca-house-41-general"},
std::string_view{"2026-ca-house-42-general"},
std::string_view{"2026-ca-house-43-general"},
std::string_view{"2026-ca-house-44-general"},
std::string_view{"2026-ca-house-45-general"},
std::string_view{"2026-ca-house-46-general"},
std::string_view{"2026-ca-house-47-general"},
std::string_view{"2026-ca-house-48-general"},
std::string_view{"2026-ca-house-49-general"},
std::string_view{"2026-ca-house-50-general"},
std::string_view{"2026-ca-house-51-general"},
std::string_view{"2026-ca-house-52-general"},
std::string_view{"2026-ca-governor-general"},
std::string_view{"2026-co-house-01-general"},
std::string_view{"2026-co-house-02-general"},
std::string_view{"2026-co-house-03-general"},
std::string_view{"2026-co-house-04-general"},
std::string_view{"2026-co-house-05-general"},
std::string_view{"2026-co-house-06-general"},
std::string_view{"2026-co-house-07-general"},
std::string_view{"2026-co-house-08-general"},
std::string_view{"2026-co-senate-general"},
std::string_view{"2026-co-governor-general"},
std::string_view{"2026-ct-house-01-general"},
std::string_view{"2026-ct-house-02-general"},
std::string_view{"2026-ct-house-03-general"},
std::string_view{"2026-ct-house-04-general"},
std::string_view{"2026-ct-house-05-general"},
std::string_view{"2026-ct-governor-general"},
std::string_view{"2026-de-house-01-general"},
std::string_view{"2026-de-senate-general"},
std::string_view{"2026-fl-house-01-general"},
std::string_view{"2026-fl-house-02-general"},
std::string_view{"2026-fl-house-03-general"},
std::string_view{"2026-fl-house-04-general"},
std::string_view{"2026-fl-house-05-general"},
std::string_view{"2026-fl-house-06-general"},
std::string_view{"2026-fl-house-07-general"},
std::string_view{"2026-fl-house-08-general"},
std::string_view{"2026-fl-house-09-general"},
std::string_view{"2026-fl-house-10-general"},
std::string_view{"2026-fl-house-11-general"},
std::string_view{"2026-fl-house-12-general"},
std::string_view{"2026-fl-house-13-general"},
std::string_view{"2026-fl-house-14-general"},
std::string_view{"2026-fl-house-15-general"},
std::string_view{"2026-fl-house-16-general"},
std::string_view{"2026-fl-house-17-general"},
std::string_view{"2026-fl-house-18-general"},
std::string_view{"2026-fl-house-19-general"},
std::string_view{"2026-fl-house-20-general"},
std::string_view{"2026-fl-house-21-general"},
std::string_view{"2026-fl-house-22-general"},
std::string_view{"2026-fl-house-23-general"},
std::string_view{"2026-fl-house-24-general"},
std::string_view{"2026-fl-house-25-general"},
std::string_view{"2026-fl-house-26-general"},
std::string_view{"2026-fl-house-27-general"},
std::string_view{"2026-fl-house-28-general"},
std::string_view{"2026-fl-senate-special"},
std::string_view{"2026-fl-governor-general"},
std::string_view{"2026-ga-house-01-general"},
std::string_view{"2026-ga-house-02-general"},
std::string_view{"2026-ga-house-03-general"},
std::string_view{"2026-ga-house-04-general"},
std::string_view{"2026-ga-house-05-general"},
std::string_view{"2026-ga-house-06-general"},
std::string_view{"2026-ga-house-07-general"},
std::string_view{"2026-ga-house-08-general"},
std::string_view{"2026-ga-house-09-general"},
std::string_view{"2026-ga-house-10-general"},
std::string_view{"2026-ga-house-11-general"},
std::string_view{"2026-ga-house-12-general"},
std::string_view{"2026-ga-house-13-general"},
std::string_view{"2026-ga-house-14-general"},
std::string_view{"2026-ga-senate-general"},
std::string_view{"2026-ga-governor-general"},
std::string_view{"2026-hi-house-01-general"},
std::string_view{"2026-hi-house-02-general"},
std::string_view{"2026-hi-governor-general"},
std::string_view{"2026-id-house-01-general"},
std::string_view{"2026-id-house-02-general"},
std::string_view{"2026-id-senate-general"},
std::string_view{"2026-id-governor-general"},
std::string_view{"2026-il-house-01-general"},
std::string_view{"2026-il-house-02-general"},
std::string_view{"2026-il-house-03-general"},
std::string_view{"2026-il-house-04-general"},
std::string_view{"2026-il-house-05-general"},
std::string_view{"2026-il-house-06-general"},
std::string_view{"2026-il-house-07-general"},
std::string_view{"2026-il-house-08-general"},
std::string_view{"2026-il-house-09-general"},
std::string_view{"2026-il-house-10-general"},
std::string_view{"2026-il-house-11-general"},
std::string_view{"2026-il-house-12-general"},
std::string_view{"2026-il-house-13-general"},
std::string_view{"2026-il-house-14-general"},
std::string_view{"2026-il-house-15-general"},
std::string_view{"2026-il-house-16-general"},
std::string_view{"2026-il-house-17-general"},
std::string_view{"2026-il-senate-general"},
std::string_view{"2026-il-governor-general"},
std::string_view{"2026-in-house-01-general"},
std::string_view{"2026-in-house-02-general"},
std::string_view{"2026-in-house-03-general"},
std::string_view{"2026-in-house-04-general"},
std::string_view{"2026-in-house-05-general"},
std::string_view{"2026-in-house-06-general"},
std::string_view{"2026-in-house-07-general"},
std::string_view{"2026-in-house-08-general"},
std::string_view{"2026-in-house-09-general"},
std::string_view{"2026-ia-house-01-general"},
std::string_view{"2026-ia-house-02-general"},
std::string_view{"2026-ia-house-03-general"},
std::string_view{"2026-ia-house-04-general"},
std::string_view{"2026-ia-senate-general"},
std::string_view{"2026-ia-governor-general"},
std::string_view{"2026-ks-house-01-general"},
std::string_view{"2026-ks-house-02-general"},
std::string_view{"2026-ks-house-03-general"},
std::string_view{"2026-ks-house-04-general"},
std::string_view{"2026-ks-senate-general"},
std::string_view{"2026-ks-governor-general"},
std::string_view{"2026-ky-house-01-general"},
std::string_view{"2026-ky-house-02-general"},
std::string_view{"2026-ky-house-03-general"},
std::string_view{"2026-ky-house-04-general"},
std::string_view{"2026-ky-house-05-general"},
std::string_view{"2026-ky-house-06-general"},
std::string_view{"2026-ky-senate-general"},
std::string_view{"2026-la-house-01-general"},
std::string_view{"2026-la-house-02-general"},
std::string_view{"2026-la-house-03-general"},
std::string_view{"2026-la-house-04-general"},
std::string_view{"2026-la-house-05-general"},
std::string_view{"2026-la-house-06-general"},
std::string_view{"2026-la-senate-general"},
std::string_view{"2026-me-house-01-general"},
std::string_view{"2026-me-house-02-general"},
std::string_view{"2026-me-senate-general"},
std::string_view{"2026-me-governor-general"},
std::string_view{"2026-md-house-01-general"},
std::string_view{"2026-md-house-02-general"},
std::string_view{"2026-md-house-03-general"},
std::string_view{"2026-md-house-04-general"},
std::string_view{"2026-md-house-05-general"},
std::string_view{"2026-md-house-06-general"},
std::string_view{"2026-md-house-07-general"},
std::string_view{"2026-md-house-08-general"},
std::string_view{"2026-md-governor-general"},
std::string_view{"2026-ma-house-01-general"},
std::string_view{"2026-ma-house-02-general"},
std::string_view{"2026-ma-house-03-general"},
std::string_view{"2026-ma-house-04-general"},
std::string_view{"2026-ma-house-05-general"},
std::string_view{"2026-ma-house-06-general"},
std::string_view{"2026-ma-house-07-general"},
std::string_view{"2026-ma-house-08-general"},
std::string_view{"2026-ma-house-09-general"},
std::string_view{"2026-ma-senate-general"},
std::string_view{"2026-ma-governor-general"},
std::string_view{"2026-mi-house-01-general"},
std::string_view{"2026-mi-house-02-general"},
std::string_view{"2026-mi-house-03-general"},
std::string_view{"2026-mi-house-04-general"},
std::string_view{"2026-mi-house-05-general"},
std::string_view{"2026-mi-house-06-general"},
std::string_view{"2026-mi-house-07-general"},
std::string_view{"2026-mi-house-08-general"},
std::string_view{"2026-mi-house-09-general"},
std::string_view{"2026-mi-house-10-general"},
std::string_view{"2026-mi-house-11-general"},
std::string_view{"2026-mi-house-12-general"},
std::string_view{"2026-mi-house-13-general"},
std::string_view{"2026-mi-senate-general"},
std::string_view{"2026-mi-governor-general"},
std::string_view{"2026-mn-house-01-general"},
std::string_view{"2026-mn-house-02-general"},
std::string_view{"2026-mn-house-03-general"},
std::string_view{"2026-mn-house-04-general"},
std::string_view{"2026-mn-house-05-general"},
std::string_view{"2026-mn-house-06-general"},
std::string_view{"2026-mn-house-07-general"},
std::string_view{"2026-mn-house-08-general"},
std::string_view{"2026-mn-senate-general"},
std::string_view{"2026-mn-governor-general"},
std::string_view{"2026-ms-house-01-general"},
std::string_view{"2026-ms-house-02-general"},
std::string_view{"2026-ms-house-03-general"},
std::string_view{"2026-ms-house-04-general"},
std::string_view{"2026-ms-senate-general"},
std::string_view{"2026-mo-house-01-general"},
std::string_view{"2026-mo-house-02-general"},
std::string_view{"2026-mo-house-03-general"},
std::string_view{"2026-mo-house-04-general"},
std::string_view{"2026-mo-house-05-general"},
std::string_view{"2026-mo-house-06-general"},
std::string_view{"2026-mo-house-07-general"},
std::string_view{"2026-mo-house-08-general"},
std::string_view{"2026-mt-house-01-general"},
std::string_view{"2026-mt-house-02-general"},
std::string_view{"2026-mt-senate-general"},
std::string_view{"2026-ne-house-01-general"},
std::string_view{"2026-ne-house-02-general"},
std::string_view{"2026-ne-house-03-general"},
std::string_view{"2026-ne-senate-general"},
std::string_view{"2026-ne-governor-general"},
std::string_view{"2026-nv-house-01-general"},
std::string_view{"2026-nv-house-02-general"},
std::string_view{"2026-nv-house-03-general"},
std::string_view{"2026-nv-house-04-general"},
std::string_view{"2026-nv-governor-general"},
std::string_view{"2026-nh-house-01-general"},
std::string_view{"2026-nh-house-02-general"},
std::string_view{"2026-nh-senate-general"},
std::string_view{"2026-nh-governor-general"},
std::string_view{"2026-nj-house-01-general"},
std::string_view{"2026-nj-house-02-general"},
std::string_view{"2026-nj-house-03-general"},
std::string_view{"2026-nj-house-04-general"},
std::string_view{"2026-nj-house-05-general"},
std::string_view{"2026-nj-house-06-general"},
std::string_view{"2026-nj-house-07-general"},
std::string_view{"2026-nj-house-08-general"},
std::string_view{"2026-nj-house-09-general"},
std::string_view{"2026-nj-house-10-general"},
std::string_view{"2026-nj-house-11-general"},
std::string_view{"2026-nj-house-12-general"},
std::string_view{"2026-nj-senate-general"},
std::string_view{"2026-nm-house-01-general"},
std::string_view{"2026-nm-house-02-general"},
std::string_view{"2026-nm-house-03-general"},
std::string_view{"2026-nm-senate-general"},
std::string_view{"2026-nm-governor-general"},
std::string_view{"2026-ny-house-01-general"},
std::string_view{"2026-ny-house-02-general"},
std::string_view{"2026-ny-house-03-general"},
std::string_view{"2026-ny-house-04-general"},
std::string_view{"2026-ny-house-05-general"},
std::string_view{"2026-ny-house-06-general"},
std::string_view{"2026-ny-house-07-general"},
std::string_view{"2026-ny-house-08-general"},
std::string_view{"2026-ny-house-09-general"},
std::string_view{"2026-ny-house-10-general"},
std::string_view{"2026-ny-house-11-general"},
std::string_view{"2026-ny-house-12-general"},
std::string_view{"2026-ny-house-13-general"},
std::string_view{"2026-ny-house-14-general"},
std::string_view{"2026-ny-house-15-general"},
std::string_view{"2026-ny-house-16-general"},
std::string_view{"2026-ny-house-17-general"},
std::string_view{"2026-ny-house-18-general"},
std::string_view{"2026-ny-house-19-general"},
std::string_view{"2026-ny-house-20-general"},
std::string_view{"2026-ny-house-21-general"},
std::string_view{"2026-ny-house-22-general"},
std::string_view{"2026-ny-house-23-general"},
std::string_view{"2026-ny-house-24-general"},
std::string_view{"2026-ny-house-25-general"},
std::string_view{"2026-ny-house-26-general"},
std::string_view{"2026-ny-governor-general"},
std::string_view{"2026-nc-house-01-general"},
std::string_view{"2026-nc-house-02-general"},
std::string_view{"2026-nc-house-03-general"},
std::string_view{"2026-nc-house-04-general"},
std::string_view{"2026-nc-house-05-general"},
std::string_view{"2026-nc-house-06-general"},
std::string_view{"2026-nc-house-07-general"},
std::string_view{"2026-nc-house-08-general"},
std::string_view{"2026-nc-house-09-general"},
std::string_view{"2026-nc-house-10-general"},
std::string_view{"2026-nc-house-11-general"},
std::string_view{"2026-nc-house-12-general"},
std::string_view{"2026-nc-house-13-general"},
std::string_view{"2026-nc-house-14-general"},
std::string_view{"2026-nc-senate-general"},
std::string_view{"2026-nd-house-01-general"},
std::string_view{"2026-oh-house-01-general"},
std::string_view{"2026-oh-house-02-general"},
std::string_view{"2026-oh-house-03-general"},
std::string_view{"2026-oh-house-04-general"},
std::string_view{"2026-oh-house-05-general"},
std::string_view{"2026-oh-house-06-general"},
std::string_view{"2026-oh-house-07-general"},
std::string_view{"2026-oh-house-08-general"},
std::string_view{"2026-oh-house-09-general"},
std::string_view{"2026-oh-house-10-general"},
std::string_view{"2026-oh-house-11-general"},
std::string_view{"2026-oh-house-12-general"},
std::string_view{"2026-oh-house-13-general"},
std::string_view{"2026-oh-house-14-general"},
std::string_view{"2026-oh-house-15-general"},
std::string_view{"2026-oh-senate-special"},
std::string_view{"2026-oh-governor-general"},
std::string_view{"2026-ok-house-01-general"},
std::string_view{"2026-ok-house-02-general"},
std::string_view{"2026-ok-house-03-general"},
std::string_view{"2026-ok-house-04-general"},
std::string_view{"2026-ok-house-05-general"},
std::string_view{"2026-ok-senate-general"},
std::string_view{"2026-ok-governor-general"},
std::string_view{"2026-or-house-01-general"},
std::string_view{"2026-or-house-02-general"},
std::string_view{"2026-or-house-03-general"},
std::string_view{"2026-or-house-04-general"},
std::string_view{"2026-or-house-05-general"},
std::string_view{"2026-or-house-06-general"},
std::string_view{"2026-or-senate-general"},
std::string_view{"2026-or-governor-general"},
std::string_view{"2026-pa-house-01-general"},
std::string_view{"2026-pa-house-02-general"},
std::string_view{"2026-pa-house-03-general"},
std::string_view{"2026-pa-house-04-general"},
std::string_view{"2026-pa-house-05-general"},
std::string_view{"2026-pa-house-06-general"},
std::string_view{"2026-pa-house-07-general"},
std::string_view{"2026-pa-house-08-general"},
std::string_view{"2026-pa-house-09-general"},
std::string_view{"2026-pa-house-10-general"},
std::string_view{"2026-pa-house-11-general"},
std::string_view{"2026-pa-house-12-general"},
std::string_view{"2026-pa-house-13-general"},
std::string_view{"2026-pa-house-14-general"},
std::string_view{"2026-pa-house-15-general"},
std::string_view{"2026-pa-house-16-general"},
std::string_view{"2026-pa-house-17-general"},
std::string_view{"2026-pa-governor-general"},
std::string_view{"2026-ri-house-01-general"},
std::string_view{"2026-ri-house-02-general"},
std::string_view{"2026-ri-senate-general"},
std::string_view{"2026-ri-governor-general"},
std::string_view{"2026-sc-house-01-general"},
std::string_view{"2026-sc-house-02-general"},
std::string_view{"2026-sc-house-03-general"},
std::string_view{"2026-sc-house-04-general"},
std::string_view{"2026-sc-house-05-general"},
std::string_view{"2026-sc-house-06-general"},
std::string_view{"2026-sc-house-07-general"},
std::string_view{"2026-sc-senate-general"},
std::string_view{"2026-sc-governor-general"},
std::string_view{"2026-sd-house-01-general"},
std::string_view{"2026-sd-senate-general"},
std::string_view{"2026-sd-governor-general"},
std::string_view{"2026-tn-house-01-general"},
std::string_view{"2026-tn-house-02-general"},
std::string_view{"2026-tn-house-03-general"},
std::string_view{"2026-tn-house-04-general"},
std::string_view{"2026-tn-house-05-general"},
std::string_view{"2026-tn-house-06-general"},
std::string_view{"2026-tn-house-07-general"},
std::string_view{"2026-tn-house-08-general"},
std::string_view{"2026-tn-house-09-general"},
std::string_view{"2026-tn-senate-general"},
std::string_view{"2026-tn-governor-general"},
std::string_view{"2026-tx-house-01-general"},
std::string_view{"2026-tx-house-02-general"},
std::string_view{"2026-tx-house-03-general"},
std::string_view{"2026-tx-house-04-general"},
std::string_view{"2026-tx-house-05-general"},
std::string_view{"2026-tx-house-06-general"},
std::string_view{"2026-tx-house-07-general"},
std::string_view{"2026-tx-house-08-general"},
std::string_view{"2026-tx-house-09-general"},
std::string_view{"2026-tx-house-10-general"},
std::string_view{"2026-tx-house-11-general"},
std::string_view{"2026-tx-house-12-general"},
std::string_view{"2026-tx-house-13-general"},
std::string_view{"2026-tx-house-14-general"},
std::string_view{"2026-tx-house-15-general"},
std::string_view{"2026-tx-house-16-general"},
std::string_view{"2026-tx-house-17-general"},
std::string_view{"2026-tx-house-18-general"},
std::string_view{"2026-tx-house-19-general"},
std::string_view{"2026-tx-house-20-general"},
std::string_view{"2026-tx-house-21-general"},
std::string_view{"2026-tx-house-22-general"},
std::string_view{"2026-tx-house-23-general"},
std::string_view{"2026-tx-house-24-general"},
std::string_view{"2026-tx-house-25-general"},
std::string_view{"2026-tx-house-26-general"},
std::string_view{"2026-tx-house-27-general"},
std::string_view{"2026-tx-house-28-general"},
std::string_view{"2026-tx-house-29-general"},
std::string_view{"2026-tx-house-30-general"},
std::string_view{"2026-tx-house-31-general"},
std::string_view{"2026-tx-house-32-general"},
std::string_view{"2026-tx-house-33-general"},
std::string_view{"2026-tx-house-34-general"},
std::string_view{"2026-tx-house-35-general"},
std::string_view{"2026-tx-house-36-general"},
std::string_view{"2026-tx-house-37-general"},
std::string_view{"2026-tx-house-38-general"},
std::string_view{"2026-tx-senate-general"},
std::string_view{"2026-tx-governor-general"},
std::string_view{"2026-ut-house-01-general"},
std::string_view{"2026-ut-house-02-general"},
std::string_view{"2026-ut-house-03-general"},
std::string_view{"2026-ut-house-04-general"},
std::string_view{"2026-vt-house-01-general"},
std::string_view{"2026-vt-governor-general"},
std::string_view{"2026-va-house-01-general"},
std::string_view{"2026-va-house-02-general"},
std::string_view{"2026-va-house-03-general"},
std::string_view{"2026-va-house-04-general"},
std::string_view{"2026-va-house-05-general"},
std::string_view{"2026-va-house-06-general"},
std::string_view{"2026-va-house-07-general"},
std::string_view{"2026-va-house-08-general"},
std::string_view{"2026-va-house-09-general"},
std::string_view{"2026-va-house-10-general"},
std::string_view{"2026-va-house-11-general"},
std::string_view{"2026-va-senate-general"},
std::string_view{"2026-wa-house-01-general"},
std::string_view{"2026-wa-house-02-general"},
std::string_view{"2026-wa-house-03-general"},
std::string_view{"2026-wa-house-04-general"},
std::string_view{"2026-wa-house-05-general"},
std::string_view{"2026-wa-house-06-general"},
std::string_view{"2026-wa-house-07-general"},
std::string_view{"2026-wa-house-08-general"},
std::string_view{"2026-wa-house-09-general"},
std::string_view{"2026-wa-house-10-general"},
std::string_view{"2026-wv-house-01-general"},
std::string_view{"2026-wv-house-02-general"},
std::string_view{"2026-wv-senate-general"},
std::string_view{"2026-wi-house-01-general"},
std::string_view{"2026-wi-house-02-general"},
std::string_view{"2026-wi-house-03-general"},
std::string_view{"2026-wi-house-04-general"},
std::string_view{"2026-wi-house-05-general"},
std::string_view{"2026-wi-house-06-general"},
std::string_view{"2026-wi-house-07-general"},
std::string_view{"2026-wi-house-08-general"},
std::string_view{"2026-wi-governor-general"},
std::string_view{"2026-wy-house-01-general"},
std::string_view{"2026-wy-senate-general"},
std::string_view{"2026-wy-governor-general"},
};
}
+128
View File
@@ -0,0 +1,128 @@
#include "results.hpp"
#include "domain.hpp"
#include <algorithm>
#include <chrono>
#include <map>
namespace {
auto timestamp() -> std::int64_t {
return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
}
auto observation(fpol::database &database, std::string const &race, std::string const &kind) -> boost::json::value {
auto row = database.one("SELECT o.body,o.source_time,o.received_at,s.id AS source_id,s.kind,s.url,s.last_checked,s.last_success,s.last_error,s.verified,s.enabled FROM current_observations c JOIN observations o ON o.id=c.observation_id JOIN sources s ON s.id=o.source_id JOIN coverage v ON v.race_id=c.race_id WHERE c.race_id=? AND c.kind=? AND s.id=CASE WHEN c.kind='counts' THEN v.counts_source ELSE v.outcome_source END", {race, kind});
if (row.empty()) {
return nullptr;
}
auto body = boost::json::parse(fpol::text(row, "body")).as_object();
row.erase("body");
row["source_name"] = fpol::text(row, "kind") == "openamerica" ? "Official state election offices via Open America" : fpol::text(row, "source_id");
row["stale"] = row.at("last_success").is_null() || timestamp() - row.at("last_success").as_int64() > 180 || !row.at("last_error").is_null();
body["provenance"] = row;
return body;
}
}
auto fpol::current_results(fpol::database &database, boost::json::value const &document) -> boost::json::value {
auto races = boost::json::array{};
for (auto const &race : document.at("races").as_array()) {
auto id = fpol::text(race, "id");
auto row = race.as_object();
auto names = boost::json::array{};
for (auto const &candidate : document.at("candidates").as_array()) {
if (fpol::text(candidate, "race_id") == id) {
names.emplace_back(boost::json::object{{"id", candidate.at("id")}, {"name", candidate.at("name")}, {"affiliation", candidate.at("affiliation")}});
}
}
row["candidates"] = names;
row["counts"] = observation(database, id, "counts");
row["outcome"] = observation(database, id, "outcome");
races.emplace_back(row);
}
return boost::json::object{{"catalog_id", document.at("id")}, {"mode", document.at("mode")}, {"races", races}, {"formula", "(November first-round vote share + 50 for the eventual called/certified winner) × frozen race rating"}};
}
auto fpol::standings(fpol::database &database, boost::json::object const &league) -> boost::json::value {
if (!league.at("rules_json").is_null()) {
fpol::ensure(fpol::text(boost::json::parse(fpol::text(league, "rules_json")), "version") == "v2-november-share", "Unsupported frozen scoring rules", 409);
}
auto rows = boost::json::array{};
auto document = league.at("catalog_id").is_null() ? boost::json::value{} : database.document(fpol::text(league, "catalog_id"), "catalog");
auto candidates = std::map<std::string, boost::json::value>{};
auto races = std::map<std::string, boost::json::value>{};
auto ratings = std::map<std::string, std::int64_t>{};
if (!document.is_null()) {
for (auto const &candidate : document.at("candidates").as_array()) {
candidates.emplace(fpol::text(candidate, "id"), candidate);
}
auto results = current_results(database, document);
for (auto const &race : results.at("races").as_array()) {
races.emplace(fpol::text(race, "id"), race);
}
}
if (!league.at("ratings_id").is_null()) {
auto snapshot = database.document(fpol::text(league, "ratings_id"), "ratings");
auto multipliers = std::map<std::string, std::int64_t>{{"safe", 100}, {"likely", 110}, {"lean", 125}, {"toss_up", 150}};
for (auto const &entry : snapshot.at("entries").as_array()) {
ratings.emplace(fpol::text(entry, "race_id"), multipliers.at(fpol::text(entry, "rating")));
}
}
for (auto const &team : database.query("SELECT id,name FROM teams WHERE league_id=? ORDER BY id", {fpol::text(league, "id")})) {
auto roster = boost::json::array{};
std::int64_t subtotal = 0;
auto known = 0;
auto certified = 0;
for (auto const &pick : database.query("SELECT candidate_id,slot FROM picks WHERE team_id=? ORDER BY number", {fpol::text(team, "id")})) {
auto id = fpol::text(pick, "candidate_id");
auto candidate = candidates.at(id);
auto race_id = fpol::text(candidate, "race_id");
auto race = races.at(race_id);
auto row = boost::json::object{{"candidate", candidate}, {"slot", pick.at("slot")}, {"points", nullptr}, {"share", nullptr}, {"win_bonus", 0}, {"rating", nullptr}, {"counts", race.at("counts")}, {"outcome", race.at("outcome")}};
auto const &counts = race.at("counts");
auto const &outcome = race.at("outcome");
auto winner = !outcome.is_null() && !outcome.at("winner_id").is_null() && fpol::text(outcome, "winner_id") == id && (fpol::text(outcome, "status") == "called" || fpol::text(outcome, "status") == "certified");
row["win_bonus"] = winner ? 50 : 0;
if (!counts.is_null() && ratings.contains(race_id)) {
auto total = fpol::integer(counts, "total_votes");
for (auto const &entry : counts.at("votes").as_array()) {
if (fpol::text(entry, "candidate_id") == id && total > 0) {
auto votes = fpol::integer(entry, "votes");
auto multiplier = ratings.at(race_id);
auto numerator = (votes * 100 + (winner ? total * 50 : 0)) * multiplier;
auto cents = (numerator + total / 2) / total;
subtotal += cents;
++known;
if (!outcome.is_null() && fpol::text(outcome, "status") == "certified") {
++certified;
}
row["points"] = static_cast<double>(cents) / 100;
row["share"] = static_cast<double>(votes) * 100 / static_cast<double>(total);
row["win_bonus"] = winner ? 50 : 0;
row["rating"] = static_cast<double>(multiplier) / 100;
}
}
}
roster.emplace_back(row);
}
rows.emplace_back(boost::json::object{{"team", team}, {"roster", roster}, {"known_scores", known}, {"missing_scores", 12 - known}, {"known_subtotal", known ? boost::json::value{static_cast<double>(subtotal) / 100} : boost::json::value{}}, {"total", known == 12 ? boost::json::value{static_cast<double>(subtotal) / 100} : boost::json::value{}}, {"provisional", certified != 12}, {"rank", nullptr}});
}
auto score = [](boost::json::value const &row) { return row.at("known_subtotal").is_null() ? -1.0 : row.at("known_subtotal").as_double(); };
std::stable_sort(rows.begin(), rows.end(), [&](auto const &left, auto const &right) { return score(left) > score(right); });
std::int64_t rank = 0;
double previous = -2;
for (std::size_t index = 0; index < rows.size(); ++index) {
auto value = score(rows[index]);
if (value < 0) {
continue;
}
if (value != previous) {
rank = static_cast<std::int64_t>(index + 1);
}
rows[index].as_object()["rank"] = rank;
previous = value;
}
return boost::json::object{{"teams", rows}, {"rules_version", "v2-november-share"}, {"provisional", std::any_of(rows.begin(), rows.end(), [](auto const &row) { return row.at("provisional").as_bool(); })}};
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include "database.hpp"
namespace fpol {
auto current_results(fpol::database &database, boost::json::value const &catalog) -> boost::json::value;
auto standings(fpol::database &database, boost::json::object const &league) -> boost::json::value;
}
+111
View File
@@ -1,4 +1,9 @@
#include "server.hpp"
#include "store.hpp"
#include "application.hpp"
#include <boost/asio/steady_timer.hpp>
#include <boost/asio/use_awaitable.hpp>
#include <boost/asio/redirect_error.hpp>
#include <chrono>
#include <csignal>
@@ -9,14 +14,17 @@
#include <memory>
#include <stdexcept>
#include <string>
#include <set>
#include <utility>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/signal_set.hpp>
#include <boost/asio/use_future.hpp>
#include <boost/json/object.hpp>
#include <ligo.hpp>
#include <ligo/detail/errors.hpp>
#include <modulus.hpp>
@@ -114,18 +122,121 @@ auto fpol::run(fpol::server_options const &options) -> int {
if (!options.web_root.empty()) {
add_web_routes(router, options.web_root);
}
auto store = fpol::store{io.get_executor(), options.database};
auto initialized = boost::asio::co_spawn(io, store.initialize(), boost::asio::use_future);
io.run();
initialized.get();
io.restart();
auto application = fpol::application{{options.database, options.allow_demo, {}}};
auto restarted = boost::asio::co_spawn(io, application.tick(true), boost::asio::use_future);
io.run();
restarted.get();
io.restart();
auto reads = std::set<std::string>{"auth/me", "leagues", "league", "catalogs", "candidates", "results", "standings", "admin/sources"};
auto actions = reads;
for (auto const &action : {"auth/login", "auth/register", "auth/logout", "auth/password", "auth/reset", "league/create", "league/join", "league/invite", "league/revoke-invites", "league/remove", "league/settings", "league/name", "league/ratings", "team/name", "draft/queue", "draft/pick", "draft/start", "draft/pause", "draft/resume"}) {
actions.insert(action);
}
for (auto const &action : actions) {
auto method = reads.contains(action) ? "GET" : "POST";
router.add_response_route(method, "/api/" + action, [&application, &options, action, read = reads.contains(action)](ligo::request_context context, boost::json::value input) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
auto session = std::string{};
auto cookie = context.headers["cookie"];
for (std::size_t start = 0; start < cookie.size();) {
auto end = cookie.find(';', start);
if (end == std::string::npos) {
end = cookie.size();
}
auto part = cookie.substr(start, end - start);
auto first = part.find_first_not_of(' ');
if (first != std::string::npos) {
part.erase(0, first);
}
if (part.starts_with("fpol_session=")) {
if (!session.empty()) {
session.clear();
break;
}
session = part.substr(13);
}
start = end + 1;
}
auto result = boost::json::value{};
if (!read && (context.headers["origin"] != options.origin || context.headers["x-fpol-request"] != "1")) {
result = boost::json::object{{"ok", false}, {"status", 403}, {"message", "Request origin is not allowed"}};
} else {
if (read) {
input = boost::json::object{};
for (auto const &[key, value] : context.query) {
input.as_object()[key] = value;
}
}
result = co_await application.handle(action, std::move(input), session, context.headers["x-csrf-token"], context.peer);
}
auto response = ligo::service_response{};
response.status = static_cast<std::uint16_t>(result.at("status").as_int64());
response.headers = {{"Cache-Control", "no-store"}, {"X-Content-Type-Options", "nosniff"}};
auto suffix = std::string{"; Path=/; HttpOnly; SameSite=Lax"} + (options.secure_cookies ? "; Secure" : "");
if (auto token = result.as_object().if_contains("session_token")) {
response.headers.emplace_back("Set-Cookie", "fpol_session=" + std::string{token->as_string()} + "; Max-Age=2592000" + suffix);
result.as_object().erase("session_token");
}
if (result.as_object().contains("clear_session")) {
response.headers.emplace_back("Set-Cookie", "fpol_session=; Max-Age=0" + suffix);
result.as_object().erase("clear_session");
}
response.body = boost::json::serialize(result);
co_return response;
});
}
router.add_route("GET", "/api/rules", [&store](ligo::request_context, boost::json::value input) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> { co_return co_await store.handle("rules", std::move(input)); });
// The snapshot simulator stays a local development tool, explicitly enabled.
if (options.allow_demo) {
for (auto const &action : {"catalog", "capacity", "score-preview"}) {
auto method = std::string_view{action} == "catalog" ? "GET" : "POST";
router.add_route(method, std::string{"/api/"} + action, [&store, action = std::string{action}](ligo::request_context, boost::json::value input) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
try {
co_return co_await store.handle(action, std::move(input));
} catch (std::exception const &error) {
co_return std::unexpected(ligo::detail::make_error(ligo::detail::error_invalid_argument, error.what()));
}
});
}
}
auto http_options = ligo::http::server_options{};
http_options.parse_query = true;
http_options.max_body_size = 64 * 1024;
http_options.on_error = [](modulus::error error) { modulus::log_warn(error.describe()); };
auto server = ligo::http::server{io, {.host = options.host, .port = options.port}, std::move(router), std::move(http_options)};
auto signals = boost::asio::signal_set{io, SIGINT, SIGTERM};
auto timer = boost::asio::steady_timer{io};
auto stopping = false;
auto started = server.start();
if (!started) {
modulus::log_error(started.error().describe());
return 1;
}
auto exit_code = 0;
auto ticking = [&]() -> boost::asio::awaitable<void> {
while (!stopping) {
timer.expires_after(std::chrono::seconds{1});
auto error = boost::system::error_code{};
co_await timer.async_wait(boost::asio::redirect_error(boost::asio::use_awaitable, error));
if (error || stopping) {
break;
}
try {
co_await application.tick();
} catch (std::exception const &failure) {
modulus::log_error(failure.what());
}
}
};
boost::asio::co_spawn(io, ticking(), [&](std::exception_ptr failure) {if(failure){exit_code=1;io.stop();} });
signals.async_wait([&](boost::system::error_code error, int) {
if (!error) {
stopping = true;
timer.cancel();
modulus::log_info("Stopping server");
boost::asio::co_spawn(io, stop_server(server), [&](std::exception_ptr failure) {
if (failure) {
+4
View File
@@ -11,6 +11,10 @@ struct server_options {
std::string host{"127.0.0.1"};
std::uint16_t port{8080};
std::filesystem::path web_root{};
std::filesystem::path database{"var/fpol.sqlite"};
bool allow_demo{false};
bool secure_cookies{false};
std::string origin{"http://127.0.0.1:8080"};
};
auto run(fpol::server_options const &options) -> int;
+124
View File
@@ -0,0 +1,124 @@
#include "store.hpp"
#include "domain.hpp"
#include "database.hpp"
#include <fstream>
#include <iterator>
#include <stdexcept>
namespace {
auto database_path(std::filesystem::path const &path) -> std::string {
if (path.has_parent_path()) {
std::filesystem::create_directories(path.parent_path());
}
return path.string();
}
template <typename VALUE>
auto checked(std::expected<VALUE, modulus::error> result) -> VALUE {
if (!result) {
throw std::runtime_error(result.error().describe());
}
if constexpr (!std::is_void_v<VALUE>) {
return std::move(*result);
}
}
auto find(boost::json::array const &documents, std::string const &id, std::string const &kind) -> boost::json::value const & {
for (auto const &document : documents) {
if (fpol::text(document, "id") == id && fpol::text(document, "kind") == kind) {
return document;
}
}
throw std::invalid_argument("Unknown " + kind + " snapshot: " + id);
}
auto create_schema(noto::connection &connection) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto result = co_await connection.async_execute("CREATE TABLE documents (id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('catalog','ratings','results')), "
"catalog_id TEXT REFERENCES documents(id), as_of TEXT NOT NULL, body TEXT NOT NULL)");
if (!result) {
co_return std::unexpected(result.error());
}
co_return std::expected<void, modulus::error>{};
}
}
fpol::store::store(boost::asio::any_io_executor executor, std::filesystem::path const &path)
: connection_{executor, noto::file_connection_config(database_path(path))} {}
auto fpol::store::initialize() -> boost::asio::awaitable<void> {
checked(co_await connection_.async_connect());
checked(co_await connection_.async_set_foreign_keys(true));
checked(co_await connection_.async_set_busy_timeout(std::chrono::milliseconds{5000}));
checked(co_await connection_.async_journal_mode("WAL"));
auto migrator = noto::migrator{connection_};
auto migrations = std::vector<noto::migration>{{"001_snapshots", "Immutable catalog, rating and result snapshots", "001-v1", create_schema, {}}, fpol::game_migration()};
checked(co_await migrator.async_migrate(std::move(migrations)));
}
auto fpol::store::documents() -> boost::asio::awaitable<boost::json::array> {
auto result = checked(co_await connection_.async_execute("SELECT body FROM documents ORDER BY as_of, id"));
auto documents = boost::json::array{};
for (auto const &row : result.rows) {
documents.emplace_back(boost::json::parse(std::get<std::string>(row.at(0).value)));
}
co_return documents;
}
auto fpol::store::import_document(boost::json::value document) -> boost::asio::awaitable<boost::json::value> {
auto all = co_await documents();
auto kind = fpol::text(document, "kind");
auto id = fpol::text(document, "id");
auto catalog = fpol::catalog{};
if (kind != "catalog") {
catalog = fpol::read_catalog(find(all, fpol::text(document, "catalog_id"), "catalog"));
}
fpol::validate_document(document, kind == "catalog" ? nullptr : &catalog);
for (auto const &existing : all) {
if (fpol::text(existing, "id") == id) {
if (existing != document) {
throw std::invalid_argument("Snapshot IDs are immutable; use a new ID for a correction: " + id);
}
co_return boost::json::object{{"id", id}, {"imported", false}};
}
}
// A single parameterized INSERT is atomic. There are no update/delete import paths.
auto parameters = std::vector<noto::sqlite_value>{id, kind,
kind == "catalog" ? noto::sqlite_value{nullptr} : noto::sqlite_value{catalog.id},
fpol::text(document, "as_of"), boost::json::serialize(document)};
checked(co_await connection_.async_execute("INSERT INTO documents(id,kind,catalog_id,as_of,body) VALUES (?,?,?,?,?)", std::move(parameters)));
co_return boost::json::object{{"id", id}, {"imported", true}};
}
auto fpol::store::handle(std::string action, boost::json::value request) -> boost::asio::awaitable<boost::json::value> {
if (action == "rules") {
co_return fpol::rules();
}
auto all = co_await documents();
if (action == "catalog") {
co_return boost::json::object{{"documents", all}};
}
auto catalog = fpol::read_catalog(find(all, fpol::text(request, "catalog_id"), "catalog"));
if (action == "capacity") {
co_return fpol::capacity(catalog, fpol::integer(request, "players"));
}
if (action == "score-preview") {
co_return fpol::score_preview(catalog, find(all, fpol::text(request, "ratings_id"), "ratings"), find(all, fpol::text(request, "results_id"), "results"), request);
}
throw std::invalid_argument("Unknown action: " + action);
}
auto fpol::read_json(std::filesystem::path const &path) -> boost::json::value {
auto stream = std::ifstream{path, std::ios::binary};
if (!stream) {
throw std::invalid_argument("Cannot read JSON file: " + path.string());
}
auto contents = std::string{std::istreambuf_iterator<char>{stream}, std::istreambuf_iterator<char>{}};
if (stream.bad()) {
throw std::runtime_error("Failed reading JSON file: " + path.string());
}
return boost::json::parse(contents);
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <filesystem>
#include <boost/asio/awaitable.hpp>
#include <boost/json.hpp>
#include <noto.hpp>
namespace fpol {
class store {
public:
store(boost::asio::any_io_executor executor, std::filesystem::path const &path);
auto initialize() -> boost::asio::awaitable<void>;
auto import_document(boost::json::value document) -> boost::asio::awaitable<boost::json::value>;
auto handle(std::string action, boost::json::value request) -> boost::asio::awaitable<boost::json::value>;
private:
auto documents() -> boost::asio::awaitable<boost::json::array>;
noto::connection connection_;
};
auto read_json(std::filesystem::path const &path) -> boost::json::value;
}
+46
View File
@@ -0,0 +1,46 @@
// Real-browser smoke test with Node's standard library and Chromium's CDP pipe.
// Usage: node tests/browser.mjs build/workspace_debug/fpol_server
import assert from 'node:assert/strict';
import {spawn, spawnSync} from 'node:child_process';
import {mkdtempSync, mkdirSync, writeFileSync, rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import path from 'node:path';
import net from 'node:net';
const root=path.resolve(import.meta.dirname,'..');
const executable=path.resolve(process.argv[2]||'build/workspace_debug/fpol_server');
const temporary=mkdtempSync(path.join(tmpdir(),'fpol-browser-'));
const database=path.join(temporary,'fpol.sqlite');
const password='browser test password';
let server,browser;
const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms));
async function until(check,description){const deadline=Date.now()+15000;while(Date.now()<deadline){const result=await check();if(result)return result;await pause(50);}throw new Error(`Timed out: ${description}`);}
function admin(args,input){const result=spawnSync(path.join(path.dirname(executable),'fpol_admin'),['--database',database,...args],{encoding:'utf8',input});assert.equal(result.status,0,result.stderr);return result.stdout;}
try{
admin(['import',path.join(root,'data/demo/catalog.json'),path.join(root,'data/demo/ratings.json')]);admin(['create-user','alice'],password+'\n');
const listener=net.createServer();await new Promise((resolve,reject)=>{listener.once('error',reject);listener.listen(0,'127.0.0.1',resolve);});const port=listener.address().port;await new Promise(resolve=>listener.close(resolve));
const origin=`http://127.0.0.1:${port}`;
let serverLog='';server=spawn(executable,['--host','127.0.0.1','--port',String(port),'--database',database,'--origin',origin,'--allow-demo','--web-root',path.join(root,'frontend/dist')]);server.stderr.on('data',data=>{serverLog+=data;});server.stdout.on('data',data=>{serverLog+=data;});
await until(()=>{if(server.exitCode!=null)throw new Error(serverLog);return serverLog.includes('Listening on');},'C++ server startup');
const flags=['--headless','--disable-gpu','--disable-background-networking','--disable-component-update','--no-first-run','--remote-debugging-pipe',`--user-data-dir=${temporary}/chrome`];
if(process.getuid?.()===0)flags.push('--no-sandbox');
browser=spawn(process.env.FPOL_CHROMIUM||'chromium',flags,{stdio:['ignore','ignore','pipe','pipe','pipe']});let browserLog='';browser.stderr.on('data',data=>{browserLog+=data;});
let counter=0,buffer='';const pending=new Map();
browser.stdio[4].on('data',chunk=>{buffer+=chunk.toString();let index;while((index=buffer.indexOf('\0'))>=0){const value=JSON.parse(buffer.slice(0,index));buffer=buffer.slice(index+1);if(value.id&&pending.has(value.id)){const promise=pending.get(value.id);pending.delete(value.id);clearTimeout(promise.timer);value.error?promise.reject(new Error(JSON.stringify(value.error))):promise.resolve(value.result);}}});
browser.on('exit',()=>{for(const promise of pending.values()){clearTimeout(promise.timer);promise.reject(new Error(`Chromium exited: ${browserLog}`));}pending.clear();});
function cdp(method,params={},sessionId){return new Promise((resolve,reject)=>{const id=++counter;const timer=setTimeout(()=>{pending.delete(id);reject(new Error(`CDP timeout: ${method}. ${browserLog}`));},15000);pending.set(id,{resolve,reject,timer});browser.stdio[3].write(JSON.stringify({id,method,params,sessionId})+'\0');});}
async function page(){const {browserContextId}=await cdp('Target.createBrowserContext');const {targetId}=await cdp('Target.createTarget',{url:'about:blank',browserContextId});const {sessionId}=await cdp('Target.attachToTarget',{targetId,flatten:true});await cdp('Page.enable',{},sessionId);await cdp('Runtime.enable',{},sessionId);
async function evaluate(expression){const result=await cdp('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true},sessionId);if(result.exceptionDetails)throw new Error(result.exceptionDetails.text+': '+result.exceptionDetails.exception?.description);return result.result.value;}
return {sessionId,evaluate,go:async(hash='')=>{await cdp('Page.navigate',{url:origin+'/'+hash},sessionId);},wait:selector=>until(()=>evaluate(`!!document.querySelector(${JSON.stringify(selector)})`),selector),click:selector=>evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`),fill:(selector,value)=>evaluate(`document.querySelector(${JSON.stringify(selector)}).value=${JSON.stringify(value)}`),text:()=>evaluate('document.body.innerText')};
}
async function signIn(tab){await tab.wait('#signin');await tab.fill('#signin [name=username]','alice');await tab.fill('#signin [name=password]',password);await tab.click('#signin button');await tab.wait('a[href="#create"]');}
const alice=await page();await alice.go();await signIn(alice);assert.match(await alice.text(),/My leagues/);
await alice.click('a[href="#create"]');await alice.wait('#create');await alice.fill('[name=name]','Election friends');await alice.fill('[name=team_name]','The Night Owls');await alice.fill('[name=catalog_id]','demo-2026');await alice.click('#create button');await alice.wait('#invite');
const league=await alice.evaluate('location.hash.split("/")[1]');await alice.click('#invite');await alice.wait('#invite-link input');const inviteUrl=await alice.evaluate('document.querySelector("#invite-link input").value');assert.match(inviteUrl,/#join\/[a-f0-9]+$/);
const bob=await page();await bob.go(inviteUrl.split('/#')[1]? '#'+inviteUrl.split('/#')[1]:'');await bob.wait('#register');await bob.fill('#register [name=username]','bob');await bob.fill('#register [name=password]',password);await bob.fill('#register [name=team_name]','Late Calls');await bob.click('#register button');await bob.wait(`a[href="#league/${league}"]`);
await alice.go(`#draft/${league}`);await alice.wait('#start');await until(()=>alice.evaluate('!document.querySelector("#start").disabled'),'draft readiness');await alice.click('#start');await alice.wait('#pause');await bob.go(`#draft/${league}`);await bob.wait('#clock');
const active=await alice.evaluate('document.querySelector("#turn-label").innerText.includes("You’re on the clock")')?alice:bob;
await until(()=>active.evaluate('!!document.querySelector(".pick-candidate:not([disabled])")'),'legal candidate buttons');await active.click('.queue-candidate:not([disabled])');await until(()=>active.evaluate('!!document.querySelector(".unqueue")'),'saved private queue');await active.click('.pick-candidate:not([disabled])');await until(()=>active.evaluate('document.querySelector("#recent").innerText.includes("house:")||document.querySelector("#recent").innerText.includes("governor:")||document.querySelector("#recent").innerText.includes("senate:")'),'recorded pick');
const artifacts=path.join(root,'build/browser-artifacts');mkdirSync(artifacts,{recursive:true});await cdp('Emulation.setDeviceMetricsOverride',{width:1365,height:900,deviceScaleFactor:1,mobile:false},alice.sessionId);writeFileSync(path.join(artifacts,'draft-desktop.png'),Buffer.from((await cdp('Page.captureScreenshot',{format:'png'},alice.sessionId)).data,'base64'));
await alice.go(`#standings/${league}`);await alice.wait('.standing');assert.match(await alice.text(),/Provisional/);await cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true},alice.sessionId);assert.equal(await alice.evaluate('document.documentElement.scrollWidth <= 390'),true);writeFileSync(path.join(artifacts,'standings-mobile.png'),Buffer.from((await cdp('Page.captureScreenshot',{format:'png'},alice.sessionId)).data,'base64'));
console.log('Browser smoke passed: accounts, invitations, leagues, queues, picks, standings, responsive layout.');
}finally{browser?.kill('SIGTERM');server?.kill('SIGTERM');await pause(300);rmSync(temporary,{recursive:true,force:true});}
+11
View File
@@ -0,0 +1,11 @@
"""One suite entry point for the shared Modulus/Conan check target."""
import pathlib
import subprocess
import sys
ROOT = pathlib.Path(__file__).resolve().parent
status = 0
for script, arguments in (("test_core.py", [sys.argv[1]]), ("test_application.py", [sys.argv[1], sys.argv[3]]), ("test_server.py", [sys.argv[2]])):
result = subprocess.run([sys.executable, str(ROOT / script), *arguments], check=False)
status = status or result.returncode
sys.exit(status)
+331
View File
@@ -0,0 +1,331 @@
"""Real C++ service/SQLite integration without requiring a listening socket."""
import concurrent.futures
import copy
import json
import pathlib
import sqlite3
import subprocess
import sys
import tempfile
import unittest
ADMIN = pathlib.Path(sys.argv.pop(1)).resolve()
INGEST = pathlib.Path(sys.argv.pop(1)).resolve()
ROOT = pathlib.Path(__file__).resolve().parents[1]
NOW = 2000000000
PASSWORD = "a sufficiently long password"
class application_tests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.base = tempfile.TemporaryDirectory(prefix="fpol_accounts_")
cls.base_database = pathlib.Path(cls.base.name) / "base.sqlite"
cls.identities = {}
def command(*args, stdin=None):
run = subprocess.run([str(ADMIN), "--database", str(cls.base_database), "--allow-demo", *args], input=stdin, text=True, capture_output=True, timeout=30)
if run.returncode:
raise RuntimeError(run.stderr)
return json.loads(run.stdout.splitlines()[-1])
command("import", str(ROOT / "data/demo/catalog.json"), str(ROOT / "data/demo/ratings.json"), str(ROOT / "data/real/us-2026-10-04-partial.json"))
for username in ("alice", "bob", "carol"):
command("create-user", username, stdin=PASSWORD + "\n")
cls.identities[username] = command("app-request", "-", stdin=json.dumps({"action": "auth/login", "input": {"username": username, "password": PASSWORD}, "clock": NOW}))
@classmethod
def tearDownClass(cls):
cls.base.cleanup()
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="fpol_application_")
self.addCleanup(self.temp.cleanup)
self.path = pathlib.Path(self.temp.name)
self.database = self.path / "test.sqlite"
with sqlite3.connect(self.base_database) as source, sqlite3.connect(self.database) as destination:
source.backup(destination)
self.identities = copy.deepcopy(type(self).identities)
self.now = NOW
def command(self, executable, *args, stdin=None):
result = subprocess.run([str(executable), "--database", str(self.database), *map(str, args)], input=stdin, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(result.stdout.splitlines()[-1])
def request(self, action, data=None, who="alice", status=200, csrf=True, raw=False):
identity = self.identities.get(who, {})
result = self.command(ADMIN, "--allow-demo", "app-request", "-", stdin=json.dumps({"action": action, "input": data or {}, "clock": self.now,
"session": identity.get("session_token", ""), "csrf": identity.get("payload", {}).get("csrf", "") if csrf else ""}))
self.assertEqual(result.get("status", 200), status, result)
return result if raw or not result.get("ok") else result.get("payload", {})
def league(self, players=("alice", "bob"), maximum=2, demo=True):
data = {"name": "Friends", "team_name": "Alice team", "max_teams": maximum}
if demo:
data["catalog_id"] = "demo-2026"
league = self.request("league/create", data)["league_id"]
invite = self.request("league/invite", {"league_id": league})["invite"]
for player in players[1:]:
self.request("league/join", {"invite": invite, "team_name": player + " team"}, player)
return league, invite
def view(self, league, who="alice"):
return self.request("league", {"league_id": league}, who)
def start(self, players=("alice", "bob")):
league, _ = self.league(players=players, maximum=len(players))
self.request("draft/start", {"league_id": league})
return league
def turn(self, league):
view = self.view(league)
who = view["picker"]["username"]
return who, self.view(league, who)
def pick(self, league, candidate=None, request_id=None):
who, view = self.turn(league)
data = {"league_id": league, "candidate_id": candidate or view["legal_candidate_ids"][0], "pick_number": view["draft"]["pick_number"], "request_id": request_id or f"pick-{view['draft']['pick_number']}"}
return who, data, self.request("draft/pick", data, who)
def file(self, name, body):
path = self.path / name
path.write_text(json.dumps(body))
return path
def ingest(self, payload, configure=False):
if configure:
config = {"id": "fixture", "kind": "official-json", "url": "https://example.org/reviewed-test-fixture", "catalog_id": "demo-2026", "races": [{"race_id": "demo-house-01", "role": role} for role in ("counts", "outcome")]}
self.command(INGEST, "configure", self.file("source.json", config))
return self.command(INGEST, "replay", "fixture", self.file("payload.json", payload))
def test_auth_and_csrf_and_logout(self):
self.request("leagues", who="nobody", status=401)
self.request("league/create", {}, csrf=False, status=403)
self.request("auth/login", {"username": "alice", "password": "incorrect password"}, status=401)
self.request("auth/logout")
self.request("auth/me", status=401)
def test_login_rate_limit(self):
for _ in range(10):
self.request("auth/login", {"username": "nonexistent", "password": PASSWORD}, status=401)
self.request("auth/login", {"username": "nonexistent", "password": PASSWORD}, status=429)
self.now += 901
self.request("auth/login", {"username": "nonexistent", "password": PASSWORD}, status=401)
def test_invited_registration_capacity_and_atomicity(self):
league, invite = self.league(players=("alice",), maximum=2)
credentials = {"username": "david", "password": PASSWORD, "team_name": "David", "invite": invite}
registered = self.request("auth/register", credentials, who="nobody", raw=True)
self.assertEqual(registered["payload"]["admin"], 0)
self.assertEqual(len(self.view(league)["teams"]), 2)
credentials["username"] = "edward"
self.request("auth/register", credentials, who="nobody", status=409)
with sqlite3.connect(self.database) as database:
self.assertEqual(database.execute("SELECT COUNT(*) FROM users WHERE username='edward'").fetchone()[0], 0)
self.assertNotIn(invite, str(database.execute("SELECT * FROM invites").fetchall()))
def test_invite_expiry_and_revocation(self):
league, invite = self.league(players=("alice",))
self.request("league/revoke-invites", {"league_id": league})
self.request("league/join", {"invite": invite, "team_name": "Bob"}, "bob", status=404)
invite = self.request("league/invite", {"league_id": league})["invite"]
self.now += 7 * 86400
self.request("league/join", {"invite": invite, "team_name": "Bob"}, "bob", status=404)
def test_isolation_and_commissioner_permissions(self):
league, _ = self.league()
self.request("league", {"league_id": league}, "carol", status=403)
self.request("standings", {"league_id": league}, "carol", status=403)
self.request("candidates", {"league_id": league}, "carol", status=403)
self.request("draft/start", {"league_id": league}, "bob", status=403)
self.request("league/invite", {"league_id": league}, "bob", status=403)
self.assertEqual(self.request("leagues", who="carol"), [])
self.request("draft/queue", {"league_id": league}, status=400)
def test_reset_single_use_expiry_and_session_revocation(self):
reset = self.command(ADMIN, "reset-link", "alice")
# Operator reset timestamps use wall time, not the diagnostic clock.
self.now = reset["expires_at"] - 1
self.request("auth/reset", {"reset_token": reset["reset_token"], "password": PASSWORD + " new"}, who="nobody")
self.now = NOW
self.request("auth/me", status=401)
self.request("auth/reset", {"reset_token": reset["reset_token"], "password": PASSWORD}, who="nobody", status=400)
reset = self.command(ADMIN, "reset-link", "bob")
self.now = reset["expires_at"]
self.request("auth/reset", {"reset_token": reset["reset_token"], "password": PASSWORD}, who="nobody", status=400)
def test_password_change_invalidates_old_session(self):
result = self.request("auth/password", {"current_password": PASSWORD, "password": PASSWORD + " updated"}, raw=True)
self.request("auth/me", status=401)
self.identities["alice"] = result
self.assertEqual(self.request("auth/me")["username"], "alice")
def test_real_leagues_exist_before_data_ready(self):
league, _ = self.league(demo=False)
view = self.view(league)
self.assertFalse(view["readiness"]["ready"])
self.assertEqual(len(view["teams"]), 2)
self.request("draft/start", {"league_id": league}, status=409)
self.assertEqual(self.request("candidates", {"limit": 100})["total"], 115)
self.assertEqual(len(self.request("candidates", {"offset": 100, "limit": 100})["items"]), 15)
def test_private_queue_timeout_and_snake(self):
league = self.start()
who, view = self.turn(league)
candidate = view["legal_candidate_ids"][-1]
self.request("draft/queue", {"league_id": league, "candidates": [candidate]}, who)
other = "alice" if who == "bob" else "bob"
self.assertNotIn(candidate, json.dumps(self.view(league, other)["teams"]))
self.now += 120
self.request("tick")
view = self.view(league)
self.assertEqual(view["picks"][0]["candidate_id"], candidate)
self.assertEqual(view["picks"][0]["automatic"], 1)
second = view["picker"]["id"]
self.pick(league)
self.assertEqual(self.view(league)["picker"]["id"], second)
def test_pause_restart_and_frozen_settings(self):
league = self.start()
self.now += 50
self.request("draft/pause", {"league_id": league})
self.assertEqual(self.view(league)["draft"]["remaining"], 70)
self.now += 1000
self.request("tick")
self.assertEqual(len(self.view(league)["picks"]), 0)
self.request("draft/resume", {"league_id": league})
self.assertEqual(self.view(league)["draft"]["deadline"], self.now + 70)
self.request("restart")
view = self.view(league)
self.assertEqual(view["league"]["status"], "paused")
self.assertEqual(view["draft"]["remaining"], 120)
self.assertEqual(json.loads(view["league"]["rules_json"])["version"], "v2-november-share")
self.request("league/settings", {"league_id": league, "name": "Changed", "catalog_id": "demo-2026"}, status=409)
def test_idempotent_concurrent_picks_and_timeout_boundary(self):
league = self.start()
who, view = self.turn(league)
data = {"league_id": league, "candidate_id": view["legal_candidate_ids"][0], "pick_number": 0, "request_id": "same-request"}
with concurrent.futures.ThreadPoolExecutor(2) as pool:
results = list(pool.map(lambda _: self.request("draft/pick", data, who), range(2)))
self.assertEqual(results[0], results[1])
self.assertEqual(len(self.view(league)["picks"]), 1)
data["candidate_id"] = view["legal_candidate_ids"][1]
self.request("draft/pick", data, who, status=409)
who, view = self.turn(league)
self.now = view["draft"]["deadline"]
self.request("draft/pick", {"league_id": league, "candidate_id": view["legal_candidate_ids"][0], "pick_number": 1, "request_id": "expired"}, who, status=409)
self.request("tick")
self.assertEqual(len(self.view(league)["picks"]), 2)
def test_full_draft_capacity_and_league_exclusivity(self):
league = self.start(players=("alice", "bob", "carol"))
for _ in range(36):
self.now += 120
self.request("tick")
view = self.view(league)
self.assertEqual(view["league"]["status"], "drafted")
self.assertEqual(len({p["candidate_id"] for p in view["picks"]}), 36)
for team in view["teams"]:
self.assertEqual(len({p["slot"] for p in view["picks"] if p["team_id"] == team["id"]}), 12)
second = self.start()
self.pick(second, view["picks"][0]["candidate_id"])
self.assertEqual(len(self.view(second)["picks"]), 1)
def test_ingest_corrections_runoff_retraction_and_outage(self):
league = self.start()
self.pick(league, "demo-house-01-1")
counts = {"race_id": "demo-house-01", "kind": "counts", "round": "november_first", "source_time": "2026-11-04T01:00:00Z", "total_votes": 1000, "votes": [{"candidate_id": "demo-house-01-1", "votes": 400}]}
outcome = {"race_id": "demo-house-01", "kind": "outcome", "round": "general", "source_time": "2026-11-04T01:00:00Z", "status": "pending_round", "winner_id": None}
payload = {"schema_version": 1, "observations": [counts, outcome]}
result = self.ingest(payload, configure=True)
self.assertFalse(result["verified"])
def score():
return next(row["roster"][0]["points"] for row in self.request("standings", {"league_id": league})["teams"] if row["roster"])
self.assertEqual(score(), 40)
outcome.update(round="runoff", source_time="2026-12-06T01:00:00Z", status="called", winner_id="demo-house-01-1")
self.ingest({"schema_version": 1, "observations": [outcome]})
self.assertEqual(score(), 90)
outcome.update(source_time="2026-12-06T02:00:00Z", status="reporting", winner_id=None)
self.ingest({"schema_version": 1, "observations": [outcome]})
self.assertEqual(score(), 40)
counts.update(source_time="2026-11-04T03:00:00Z", votes=[{"candidate_id": "demo-house-01-1", "votes": 450}])
self.ingest({"schema_version": 1, "observations": [counts]})
self.assertEqual(score(), 45)
counts["round"] = "runoff"
self.assertTrue(self.ingest({"schema_version": 1, "observations": [counts]})["quarantined"])
self.assertEqual(score(), 45)
counts["round"] = "november_first"
counts["source_time"] = "2026-11-04T00:00:00Z"
counts["votes"][0]["votes"] = 100
self.ingest({"schema_version": 1, "observations": [counts]})
self.assertEqual(score(), 45)
def test_twelve_teams_preserve_party_capacity_and_finish(self):
league, invite = self.league(players=("alice", "bob", "carol"), maximum=12)
for index in range(9):
name = f"player_{index}"
self.identities[name] = self.request("auth/register", {"username": name, "password": PASSWORD, "team_name": name, "invite": invite}, who="nobody", raw=True)
self.request("draft/start", {"league_id": league})
self.pick(league, "demo-house-01-1")
# Once someone has their D slot, a second D would consume another team's only required supply.
with sqlite3.connect(self.database) as database:
first = database.execute("SELECT team_id FROM picks WHERE number=0 AND league_id=?", (league,)).fetchone()[0]
database.execute("UPDATE drafts SET pick_number=23 WHERE league_id=?", (league,))
who, view = self.turn(league)
self.assertEqual(view["picker"]["id"], first)
self.assertNotIn("demo-house-02-1", view["legal_candidate_ids"])
with sqlite3.connect(self.database) as database:
database.execute("UPDATE drafts SET pick_number=1 WHERE league_id=?", (league,))
for _ in range(143):
self.now += 120
self.request("tick")
view = self.view(league)
self.assertEqual(view["league"]["status"], "drafted")
self.assertEqual(len(view["picks"]), 144)
self.assertEqual(len({row["candidate_id"] for row in view["picks"]}), 144)
def test_openamerica_mapping_and_fusion_lines(self):
config = {"id": "openamerica-test", "kind": "openamerica", "url": "https://openamerica.io/elections/results.json?year=2026&office=House", "office": "House", "catalog_id": "demo-2026", "counts_until": "2099-01-01T00:00:00Z", "races": [{"race_id": "demo-house-01", "role": "counts", "provider_id": "MI-01", "candidates": {"Test Candidate|D": "demo-house-01-1", "Test Candidate|W": "demo-house-01-1", "Write-in|": None}}]}
self.command(INGEST, "configure", self.file("oa-config.json", config))
# Synthetic transport fixture. It cannot verify a real feed.
payload = {"api": 1, "year": 2026, "office": "House", "stale": False, "as_of": "2026-11-04T01:00:00+00:00", "races": {"MI-01": {"total": 100, "cands": [{"n": "Test Candidate", "p": "D", "v": 60}, {"n": "Test Candidate", "p": "W", "v": 10}]}}}
result = self.command(INGEST, "replay", "openamerica-test", self.file("oa-payload.json", payload))
self.assertEqual(result["changed"], 1)
self.assertFalse(result["verified"])
with sqlite3.connect(self.database) as database:
normalized = json.loads(database.execute("SELECT body FROM observations").fetchone()[0])
self.assertEqual(normalized["votes"], [{"candidate_id": "demo-house-01-1", "votes": 70}])
payload["races"]["MI-01"]["cands"].append({"n": "Unknown", "p": "R", "v": 20})
self.assertTrue(self.command(INGEST, "replay", "openamerica-test", self.file("oa-payload.json", payload))["quarantined"])
activation = subprocess.run([str(INGEST), "--database", str(self.database), "activate", "openamerica-test"], capture_output=True, text=True)
self.assertNotEqual(activation.returncode, 0)
def test_ratings_import_permission_immutability_and_freeze(self):
league, _ = self.league()
ratings = json.loads((ROOT / "data/demo/ratings.json").read_text())
ratings["id"] = "commissioner-ratings"
data = {"league_id": league, "document": ratings}
self.request("league/ratings", data, "bob", status=403)
self.request("league/ratings", data)
self.request("league/ratings", data)
self.assertEqual(self.view(league)["league"]["ratings_id"], ratings["id"])
ratings["entries"][0]["rating"] = "toss_up"
self.request("league/ratings", data, status=409)
self.request("draft/start", {"league_id": league})
ratings["id"] = "too-late"
self.request("league/ratings", data, status=409)
self.request("league/name", {"league_id": league, "name": "Renamed after start"})
self.assertEqual(self.view(league)["league"]["name"], "Renamed after start")
def test_one_verified_race_cannot_activate_whole_feed(self):
self.ingest({"schema_version": 1, "observations": []}, configure=True)
# Fixture-only network evidence, to exercise the coverage gate without sockets.
with sqlite3.connect(self.database) as database:
database.execute("UPDATE sources SET verified=1,last_error=NULL WHERE id='fixture'")
database.execute("INSERT INTO source_checks VALUES('fixture','demo-house-01','counts',?)", (self.now,))
result = subprocess.run([str(INGEST), "--database", str(self.database), "activate", "fixture"], capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
with sqlite3.connect(self.database) as database:
self.assertEqual(database.execute("SELECT COUNT(*) FROM coverage").fetchone()[0], 0)
def test_candidate_watch_deduplicates_and_detects_return_to_previous_payload(self):
config = {"id": "watch", "kind": "candidate-watch", "url": "https://example.org/official-candidates", "catalog_id": "demo-2026", "races": []}
self.command(INGEST, "configure", self.file("watch.json", config))
first = self.file("first.json", {"version": 1})
second = self.file("second.json", {"version": 2})
self.command(INGEST, "replay", "watch", first)
with sqlite3.connect(self.database) as database:
database.execute("UPDATE sources SET last_changed=123,etag='external-etag' WHERE id='watch'")
self.command(INGEST, "replay", "watch", first)
with sqlite3.connect(self.database) as database:
self.assertEqual(database.execute("SELECT last_changed FROM sources WHERE id='watch'").fetchone()[0], 123)
self.command(INGEST, "replay", "watch", second)
with sqlite3.connect(self.database) as database:
database.execute("UPDATE sources SET last_changed=456 WHERE id='watch'")
self.command(INGEST, "replay", "watch", first)
with sqlite3.connect(self.database) as database:
self.assertGreater(database.execute("SELECT last_changed FROM sources WHERE id='watch'").fetchone()[0], 456)
self.assertEqual(database.execute("SELECT COUNT(*) FROM source_payloads").fetchone()[0], 2)
self.assertEqual(database.execute("SELECT verified FROM sources WHERE id='watch'").fetchone()[0], 0)
def test_catalog_documents_survive_migration_and_backup(self):
backup = self.path / "backup.sqlite"
self.command(ADMIN, "backup", backup)
with sqlite3.connect(backup) as database:
self.assertEqual(database.execute("PRAGMA integrity_check").fetchone()[0], "ok")
self.assertEqual(database.execute("SELECT COUNT(*) FROM documents").fetchone()[0], 3)
hashes = database.execute("SELECT password_hash FROM users").fetchall()
self.assertEqual(len(set(row[0] for row in hashes)), 3)
self.assertTrue(all(row[0].startswith("scrypt$131072$8$1$") for row in hashes))
if __name__ == "__main__":
unittest.main()
+241
View File
@@ -0,0 +1,241 @@
"""Exercise the production C++ domain and Noto persistence without HTTP sockets."""
import copy
import contextlib
import json
import pathlib
import sqlite3
import subprocess
import sys
import tempfile
import unittest
ADMIN = pathlib.Path(sys.argv.pop(1)).resolve()
DEMO = pathlib.Path(__file__).resolve().parents[1] / "data" / "demo"
class core_tests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="fpol_core_")
self.addCleanup(self.temporary.cleanup)
self.root = pathlib.Path(self.temporary.name)
self.database = self.root / "league.sqlite"
self.counter = 0
self.catalog = json.loads((DEMO / "catalog.json").read_text())
self.ratings = json.loads((DEMO / "ratings.json").read_text())
self.results = json.loads((DEMO / "results-3.json").read_text())
self.request = json.loads((DEMO / "preview.json").read_text())
self.run_admin("import", DEMO / "catalog.json", DEMO / "ratings.json", *sorted(DEMO.glob("results-*.json")))
def run_admin(self, *arguments, succeeds=True):
result = subprocess.run([str(ADMIN), "--database", str(self.database), *map(str, arguments)], capture_output=True, text=True, timeout=10)
if succeeds:
self.assertEqual(result.returncode, 0, result.stderr)
return [json.loads(line) for line in result.stdout.splitlines()][-1]
self.assertNotEqual(result.returncode, 0, result.stdout)
return result.stderr
def file(self, value):
self.counter += 1
path = self.root / f"input-{self.counter}.json"
path.write_text(json.dumps(value))
return path
def preview(self, request=None):
return self.run_admin("score-preview", self.file(request or self.request))
def scenario(self, votes, rating="toss_up", winner=None, status="reporting", total=None):
race_id = "demo-house-01"
ratings = copy.deepcopy(self.ratings)
ratings["id"] = f"ratings-{self.counter}"
ratings["entries"] = [{"race_id": race_id, "rating": rating}]
results = copy.deepcopy(self.results)
results["id"] = f"results-{self.counter}"
results["entries"] = [{"race_id": race_id, "round": "general", "status": status,
"total_votes": sum(votes) if total is None else total,
"winner_id": f"{race_id}-{winner}" if winner else None,
"votes": [{"candidate_id": f"{race_id}-{index + 1}", "votes": count} for index, count in enumerate(votes)]}]
self.run_admin("import", self.file(ratings), self.file(results))
request = {"catalog_id": self.catalog["id"], "ratings_id": ratings["id"], "results_id": results["id"],
"roster": [{"slot": "house:democrat", "candidate_id": f"{race_id}-1"}]}
return self.preview(request)["scores"][0]
def test_rules_and_twelve_slot_roster(self):
rules = self.run_admin("rules")
self.assertEqual(len(rules["slots"]), 12)
result = self.preview()
self.assertTrue(result["complete_roster"])
self.assertEqual(len(result["scores"]), 12)
self.assertEqual(result["missing_scores"], 0)
# Each office: 151.50 toss-up winner + 15 safe loser + 7.70 + 1.88.
self.assertEqual(result["total"], 528.24)
self.assertEqual(result["baseline_subtotal"], 373.5)
def test_capacity_all_player_counts(self):
for players in range(2, 13):
result = self.run_admin("capacity", self.file({"catalog_id": self.catalog["id"], "players": players}))
self.assertTrue(result["sufficient_candidates"])
self.assertFalse(result["candidate_supply_ready"])
for players in (0, 1, 13, 2.5, "12"):
self.run_admin("capacity", self.file({"catalog_id": self.catalog["id"], "players": players}), succeeds=False)
def test_party_and_flex_capacity_are_both_required(self):
catalog = copy.deepcopy(self.catalog)
catalog["id"] = "short-catalog"
# Exactly 12 D, 12 R, 12 Other per office: every party passes, Flex cannot.
catalog["candidates"] = [candidate for candidate in catalog["candidates"] if not candidate["id"].endswith("-4")]
self.run_admin("import", self.file(catalog))
result = self.run_admin("capacity", self.file({"catalog_id": catalog["id"], "players": 12}))
self.assertFalse(result["sufficient_candidates"])
for office in result["offices"]:
for requirement in office["requirements"]:
self.assertEqual(requirement["shortfall"], 12 if requirement["group"] == "total" else 0)
def test_safe_tossup_winner_and_loser_examples(self):
self.assertEqual(self.scenario([800, 200, 0, 0], "safe", 1, "called")["points"], 130)
self.assertEqual(self.scenario([510, 490, 0, 0], winner=1, status="called")["points"], 151.5)
self.assertEqual(self.scenario([490, 510, 0, 0], winner=2, status="called")["points"], 73.5)
def test_lead_is_only_hypothetical_until_called(self):
row = self.scenario([510, 490, 0, 0])
self.assertEqual(row["points"], 76.5)
self.assertEqual(row["win_bonus"], 0)
self.assertEqual(row["leader_preview_points"], 151.5)
row = self.scenario([500, 500, 0, 0])
self.assertIsNone(row["leader_preview_points"])
self.assertFalse(row["leader_preview_available"])
def test_rounding_is_per_candidate_half_up(self):
row = self.scenario([1, 31, 0, 0], "safe")
self.assertEqual(row["vote_share"], 3.125)
self.assertEqual(row["points"], 3.13)
row = self.scenario([1, 2, 0, 0])
self.assertEqual(row["points"], 50)
def test_unaccounted_votes_do_not_create_a_leader_preview(self):
row = self.scenario([400, 100, 0, 0], total=1000)
self.assertEqual(row["points"], 60)
self.assertIsNone(row["leader_preview_points"])
self.assertFalse(row["leader_preview_available"])
def test_older_snapshot_import_keeps_observation_order(self):
self.results["id"] = "late-import-of-old-results"
self.results["as_of"] = "2026-11-04T01:30:00Z"
self.run_admin("import", self.file(self.results))
snapshots = [document["id"] for document in self.run_admin("catalog")["documents"] if document["kind"] == "results"]
self.assertEqual(snapshots, ["demo-results-0", "demo-results-1", self.results["id"], "demo-results-2", "demo-results-3", "demo-results-4"])
def test_more_own_votes_never_reduce_points(self):
previous = -1
for votes in (1, 2, 49, 50, 51, 100, 999, 999999000):
row = self.scenario([votes, 1000, 0, 0])
self.assertGreaterEqual(row["points"], previous)
previous = row["points"]
def test_missing_votes_and_ratings_are_unknown(self):
self.request["results_id"] = "demo-results-0"
result = self.preview()
self.assertIsNone(result["total"])
self.assertEqual(result["missing_scores"], 12)
self.assertTrue(all(row["points"] is None for row in result["scores"]))
self.ratings["id"] = "no-ratings"
self.ratings["entries"] = []
self.run_admin("import", self.file(self.ratings))
self.request["ratings_id"] = "no-ratings"
self.assertEqual(self.preview()["missing_scores"], 12)
def test_missing_candidate_votes_not_assumed_zero(self):
self.results["id"] = "missing-candidate"
self.results["entries"][3]["votes"] = self.results["entries"][3]["votes"][1:]
self.run_admin("import", self.file(self.results))
self.request["results_id"] = self.results["id"]
self.assertEqual(self.preview()["missing_scores"], 1)
def test_partial_roster_has_no_complete_total(self):
self.request["roster"] = self.request["roster"][:1]
result = self.preview()
self.assertTrue(result["valid"])
self.assertFalse(result["complete_roster"])
self.assertIsNone(result["total"])
self.assertEqual(result["known_subtotal"], 151.5)
def test_duplicates_and_ineligible_slots_rejected(self):
invalid = [
[self.request["roster"][0], self.request["roster"][0]],
[{"slot": "house:flex", "candidate_id": "demo-house-04-1"}, self.request["roster"][0]],
[{"slot": "senate:democrat", "candidate_id": "demo-house-04-1"}],
[{"slot": "house:republican", "candidate_id": "demo-house-04-1"}],
[{"slot": "house:other", "candidate_id": "unknown"}],
]
for roster in invalid:
request = dict(self.request, roster=roster)
result = self.preview(request)
self.assertFalse(result["valid"])
self.assertIsNone(result["total"])
def test_opposing_candidates_allowed_but_each_candidate_only_once(self):
self.request["roster"] = [{"slot": "house:democrat", "candidate_id": "demo-house-04-1"}, {"slot": "house:republican", "candidate_id": "demo-house-04-2"}]
self.assertTrue(self.preview()["valid"])
def test_corrections_are_reversible_and_history_survives_reopen(self):
original = self.preview()
self.request["results_id"] = "demo-results-4"
corrected = self.preview()
self.assertLess(corrected["total"], original["total"])
self.request["results_id"] = "demo-results-3"
self.assertEqual(self.preview(), original)
with contextlib.closing(sqlite3.connect(self.database)) as database:
self.assertEqual(database.execute("SELECT COUNT(*) FROM documents").fetchone()[0], 7)
self.assertEqual(database.execute("SELECT COUNT(*) FROM _noto_schema_migrations").fetchone()[0], 2)
def test_idempotency_and_frozen_ratings(self):
self.assertFalse(self.run_admin("import", DEMO / "ratings.json")["imported"])
self.ratings["entries"][0]["rating"] = "toss_up"
self.assertIn("immutable", self.run_admin("import", self.file(self.ratings), succeeds=False))
# Object-key order does not change document identity.
reordered = dict(reversed(list(self.catalog.items())))
self.assertFalse(self.run_admin("import", self.file(reordered))["imported"])
def test_rounds_never_combined_and_pending_round_unscored(self):
self.results["id"] = "bad-round"
self.results["entries"][0]["round"] = "runoff"
self.assertIn("round", self.run_admin("import", self.file(self.results), succeeds=False))
row = self.scenario([600, 400, 0, 0], status="pending_round")
self.assertIsNone(row["points"])
self.assertIn("resolution", row["reason"])
def test_invalid_result_imports_leave_no_partial_document(self):
entries = [
{"total_votes": -1}, {"total_votes": 1000000001}, {"total_votes": 1},
{"total_votes": None}, {"status": "final"}, {"winner_id": "demo-senate-01-1"},
{"winner_id": None}, {"status": "reporting"},
{"votes": [{"candidate_id": "demo-house-01-1", "votes": -1}]},
{"votes": [{"candidate_id": "demo-house-01-1", "votes": 1.5}]},
{"votes": [{"candidate_id": "unknown", "votes": 1}]},
]
for changes in entries:
result = copy.deepcopy(self.results)
result["id"] = f"bad-result-{self.counter}"
result["entries"][0].update(changes)
self.run_admin("import", self.file(result), succeeds=False)
self.assertEqual(len(self.run_admin("catalog")["documents"]), 7)
def test_duplicate_races_votes_and_unknown_ratings_rejected(self):
self.results["id"] = "duplicate-race"
self.results["entries"].append(self.results["entries"][0])
self.run_admin("import", self.file(self.results), succeeds=False)
self.results["entries"].pop()
self.results["entries"][0]["votes"].append(self.results["entries"][0]["votes"][0])
self.assertIn("Duplicate", self.run_admin("import", self.file(self.results), succeeds=False))
self.ratings["id"] = "bad-rating"
self.ratings["entries"][0]["rating"] = "certain"
self.run_admin("import", self.file(self.ratings), succeeds=False)
def test_fusion_lines_do_not_duplicate_candidates(self):
self.catalog["id"] = "fusion"
self.catalog["candidates"][0]["ballot_lines"].append("Another ballot party")
self.run_admin("import", self.file(self.catalog))
capacity = self.run_admin("capacity", self.file({"catalog_id": "fusion", "players": 12}))
self.assertEqual(capacity["offices"][0]["requirements"][0]["available"], 12)
self.catalog["id"] = "duplicate-candidate"
self.catalog["candidates"].append(self.catalog["candidates"][0])
self.run_admin("import", self.file(self.catalog), succeeds=False)
def test_disqualified_candidates_and_ambiguous_provider_ids_rejected(self):
self.catalog["id"] = "not-qualified"
self.catalog["candidates"][0]["qualified"] = False
self.run_admin("import", self.file(self.catalog), succeeds=False)
self.catalog["candidates"][0]["qualified"] = True
self.catalog["candidates"][1]["provider_ids"] = self.catalog["candidates"][0]["provider_ids"]
self.assertIn("provider", self.run_admin("import", self.file(self.catalog), succeeds=False))
def test_demo_and_real_snapshots_cannot_mix(self):
self.ratings["id"] = "mixed-mode"
self.ratings["mode"] = "real"
self.ratings["source"]["url"] = "https://example.org/ratings"
self.assertIn("mismatch", self.run_admin("import", self.file(self.ratings), succeeds=False))
def test_timestamp_validation(self):
for stamp in ("yesterday", "2026-02-30T00:00:00Z", "2026-10-01T24:00:00Z", "2026-10-01T00:00:00-05:00"):
self.catalog["as_of"] = stamp
self.run_admin("import", self.file(self.catalog), succeeds=False)
def test_malformed_requests_and_unknown_snapshots(self):
for request in ({}, dict(self.request, catalog_id="missing"), dict(self.request, results_id="missing"), dict(self.request, ratings_id="demo-results-1"), dict(self.request, roster="invalid")):
self.run_admin("score-preview", self.file(request), succeeds=False)
def test_missing_catalog_dependency(self):
self.ratings["catalog_id"] = "not-imported"
self.run_admin("import", self.file(self.ratings), succeeds=False)
if __name__ == "__main__":
unittest.main()
+62 -3
View File
@@ -18,7 +18,7 @@ def running_server(*arguments, stop_signal=signal.SIGTERM):
with tempfile.TemporaryDirectory(prefix="fpol_test_") as directory:
log_path = pathlib.Path(directory) / "server.log"
with log_path.open("w") as log:
process = subprocess.Popen([str(SERVER), "--port", "0", *map(str, arguments)], stdout=log, stderr=log)
process = subprocess.Popen([str(SERVER), "--port", "0", "--database", str(pathlib.Path(directory) / "league.sqlite"), *map(str, arguments)], stdout=log, stderr=log)
try:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
@@ -44,10 +44,10 @@ def running_server(*arguments, stop_signal=signal.SIGTERM):
if return_code != 0:
raise AssertionError(f"Server exited with {return_code}:\n{log_path.read_text()}")
def request(port, path, method="GET"):
def request(port, path, method="GET", body=None):
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
try:
connection.request(method, path)
connection.request(method, path, body=json.dumps(body) if body is not None else None, headers={"Content-Type": "application/json"})
response = connection.getresponse()
return response.status, response.getheader("Content-Type"), response.read()
finally:
@@ -66,6 +66,65 @@ class server_tests(unittest.TestCase):
self.assertEqual(status, 404)
self.assertEqual(json.loads(body)["code"], "not_found")
self.assertEqual(request(port, "/api/health", "POST")[0], 404)
def test_catalog_rules_and_validation_routes(self):
with running_server("--allow-demo") as port:
self.assertEqual(json.loads(request(port, "/api/catalog")[2])["payload"], {"documents": []})
self.assertEqual(len(json.loads(request(port, "/api/rules")[2])["payload"]["slots"]), 12)
status, _, body = request(port, "/api/capacity", "POST", {"catalog_id": "missing", "players": 12})
self.assertEqual(status, 400)
self.assertEqual(json.loads(body)["code"], "invalid_argument")
self.assertEqual(request(port, "/api/score-preview", "POST", {})[0], 400)
self.assertEqual(request(port, "/api/capacity")[0], 404)
def test_seeded_catalog_capacity_and_scoring(self):
demo = pathlib.Path(__file__).resolve().parents[1] / "data" / "demo"
with tempfile.TemporaryDirectory(prefix="fpol_api_") as directory:
database = pathlib.Path(directory) / "league.sqlite"
result = subprocess.run([str(SERVER.with_name("fpol_admin")), "--database", str(database), "import", str(demo / "catalog.json"), str(demo / "ratings.json"), str(demo / "results-3.json")], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
with running_server("--database", database, "--allow-demo") as port:
catalog = json.loads(request(port, "/api/catalog")[2])["payload"]
self.assertEqual(len(catalog["documents"]), 3)
status, _, body = request(port, "/api/capacity", "POST", json.loads((demo / "capacity.json").read_text()))
self.assertEqual(status, 200)
self.assertTrue(json.loads(body)["payload"]["sufficient_candidates"])
status, _, body = request(port, "/api/score-preview", "POST", json.loads((demo / "preview.json").read_text()))
self.assertEqual(status, 200)
self.assertEqual(json.loads(body)["payload"]["total"], 528.24)
def test_authenticated_cookie_headers_origin_and_query(self):
with tempfile.TemporaryDirectory(prefix="fpol_auth_http_") as directory:
database = pathlib.Path(directory) / "league.sqlite"
result = subprocess.run([str(SERVER.with_name("fpol_admin")), "--database", str(database), "create-user", "alice"], input="a strong test password\n", capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
with running_server("--database", database, "--origin", "https://game.example", "--secure-cookies") as port:
def call(path, method="GET", body=None, headers=None):
connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
try:
connection.request(method, path, body=json.dumps(body) if body is not None else None, headers={"Content-Type": "application/json", **(headers or {})})
response = connection.getresponse()
return response.status, dict(response.getheaders()), json.loads(response.read())
finally:
connection.close()
login = {"username": "alice", "password": "a strong test password"}
self.assertEqual(call("/api/auth/login", "POST", login)[0], 403)
headers = {"Origin": "https://game.example", "X-Fpol-Request": "1"}
status, response, body = call("/api/auth/login", "POST", login, headers)
self.assertEqual(status, 200)
cookie = response["Set-Cookie"]
self.assertIn("HttpOnly", cookie)
self.assertIn("SameSite=Lax", cookie)
self.assertIn("Secure", cookie)
self.assertEqual(response["Cache-Control"], "no-store")
self.assertNotIn("session_token", body)
headers["Cookie"] = cookie.split(";", 1)[0]
self.assertEqual(call("/api/auth/me", headers=headers)[2]["payload"]["username"], "alice")
self.assertEqual(call("/api/candidates?search=two+words&limit=1", headers=headers)[0], 200)
self.assertEqual(call("/api/candidates?search=%ZZ", headers=headers)[0], 400)
self.assertEqual(call("/api/candidates?limit=1&limit=2", headers=headers)[0], 400)
self.assertEqual(call("/api/auth/logout", "POST", {}, headers)[0], 403)
headers["X-CSRF-Token"] = body["payload"]["csrf"]
self.assertEqual(call("/api/auth/logout", "POST", {}, headers)[0], 200)
self.assertEqual(call("/api/auth/me", headers=headers)[0], 401)
self.assertEqual(call("/api/catalog", headers=headers)[0], 404)
def test_static_assets_and_route_boundaries(self):
with tempfile.TemporaryDirectory(prefix="fpol_web_") as directory:
root = pathlib.Path(directory)
+29
View File
@@ -0,0 +1,29 @@
"""Build this checkout against the sibling library checkouts, without publishing."""
import argparse
import pathlib
import subprocess
ROOT = pathlib.Path(__file__).resolve().parents[1]
def run(*args):
subprocess.run(list(map(str, args)), cwd=ROOT, check=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--build-type", choices=("Debug", "Release"), default="Debug")
parser.add_argument("--test", action="store_true")
options = parser.parse_args()
build = ROOT / "build" / ("workspace_" + options.build_type.lower())
for name in ("modulus", "noto", "ligo"):
source = ROOT.parent / name
if not (source / "conanfile.py").exists():
parser.error(f"Missing sibling repository: {source}")
run("conan", "export", source)
run("conan", "install", ROOT, "--output-folder", build / "dependencies", "--build=missing", "-s", "compiler.cppstd=23", "-s", "build_type=" + options.build_type, "-o", "&:with_tests=False")
run("cmake", "-S", ROOT, "-B", build, "-G", "Ninja", "-DCMAKE_TOOLCHAIN_FILE=" + str(build / "dependencies/conan_toolchain.cmake"), "-DCMAKE_BUILD_TYPE=" + options.build_type, "-DBUILD_TESTING=ON")
run("cmake", "--build", build, "--parallel", "2")
if options.test:
run("ctest", "--test-dir", build, "--output-on-failure")
if __name__ == "__main__":
main()
+57
View File
@@ -0,0 +1,57 @@
"""Regenerate fictional examples. Never use these as real election data."""
import json
import pathlib
ROOT = pathlib.Path(__file__).resolve().parents[1] / "data" / "demo"
SOURCE = {"name": "Fictional scoring fixtures", "url": "fixture:product-fpol/demo", "retrieved_at": "2026-10-01T00:00:00Z"}
def save(name, document):
(ROOT / name).write_text(json.dumps(document, indent=2) + "\n")
def header(kind, identity, label, date):
return {"schema_version": 1, "id": identity, "kind": kind, "mode": "demo", "label": label,
"as_of": date, "source": SOURCE.copy()}
def main():
ROOT.mkdir(parents=True, exist_ok=True)
catalog = header("catalog", "demo-2026", "Fictional league • 144 candidates", "2026-10-01T00:00:00Z")
catalog.update(complete=True, races=[], candidates=[])
ratings = header("ratings", "demo-ratings-v1", "Frozen fictional ratings", "2026-10-02T00:00:00Z")
ratings.update(catalog_id=catalog["id"], entries=[])
states = ["AZ", "GA", "MI", "PA", "NC", "TX", "OH", "WI", "NV", "FL", "ME", "CO"]
names = ["Avery", "Blair", "Casey", "Drew"]
roster = []
for office in ("house", "senate", "governor"):
for index, state in enumerate(states, start=1):
race_id = f"demo-{office}-{index:02}"
catalog["races"].append({"id": race_id, "office": office, "state": state, "round": "general",
"name": f"Fictional {state} {office.title()} race {index}"})
ratings["entries"].append({"race_id": race_id, "rating": ["safe", "likely", "lean", "toss_up"][(index - 1) % 4]})
for number, affiliation in enumerate(("democrat", "republican", "other", "other")):
identity = f"{race_id}-{number + 1}"
catalog["candidates"].append({"id": identity, "race_id": race_id, "name": f"{names[number]} {office.title()} {index:02}",
"affiliation": affiliation, "qualified": True, "ballot_lines": [affiliation.title()],
"provider_ids": {"fictional": identity}, "source": SOURCE.copy()})
for slot, race_number, candidate_number in (("democrat", 4, 1), ("republican", 1, 2), ("other", 2, 3), ("flex", 3, 4)):
roster.append({"slot": f"{office}:{slot}", "candidate_id": f"demo-{office}-{race_number:02}-{candidate_number}"})
save("catalog.json", catalog)
save("ratings.json", ratings)
for step, label in enumerate(("No votes yet", "Early reporting", "Lead changes", "Races called", "Corrected totals and call")):
result = header("results", f"demo-results-{step}", f"Synthetic replay {step + 1}: {label}", f"2026-11-04T0{step}:00:00Z")
result.update(catalog_id=catalog["id"], entries=[])
for index, race in enumerate(catalog["races"]):
distribution = ([800, 150, 30, 20] if index % 4 == 0 else [600, 300, 70, 30] if index % 4 == 1 else
[540, 420, 25, 15] if index % 4 == 2 else [510, 490, 0, 0])
if index % 4 == 3 and step in (2, 4):
distribution = [490, 510, 0, 0]
distribution = [0, 0, 0, 0] if step == 0 else distribution
winner = None if step < 3 else f"{race['id']}-{1 if distribution[0] > distribution[1] else 2}"
result["entries"].append({"race_id": race["id"], "round": "general", "status": "called" if step >= 3 else "reporting",
"total_votes": sum(distribution), "winner_id": winner,
"votes": [{"candidate_id": f"{race['id']}-{number + 1}", "votes": count} for number, count in enumerate(distribution)]})
save(f"results-{step}.json", result)
save("preview.json", {"catalog_id": catalog["id"], "ratings_id": ratings["id"], "results_id": "demo-results-3", "roster": roster})
save("capacity.json", {"catalog_id": catalog["id"], "players": 12})
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
"""Reproduce reviewed public candidate facts and the national contest inventory.
NC: official state-only November 3 list, report dated September 21, 2026,
pages 1–3. Ballot names and parties only; no contact/address information.
Re-running does not fetch or imply a more recent review.
"""
import csv
import json
import pathlib
import re
ROOT = pathlib.Path(__file__).resolve().parents[1]
SOURCE = {
"name": "North Carolina State Board of Elections — November 2026 candidate list, pages 1–3",
"url": "https://s3.amazonaws.com/dl.ncsbe.gov/Elections/2026/Candidate%20Filing/2026_general_candidate_list_by_contest_federal_and_state.pdf",
"retrieved_at": "2026-10-04T00:00:00Z",
}
# Reviewed transcription. Party order follows the official list.
CONTESTS = {
"senate": "GRE:Michael Dublin|DEM:Roy Cooper|LIB:Shannon W. Bray|REP:Michael Whatley",
"house-01": "DEM:Don Davis|REP:Laurie Buckhout|LIB:Tom Bailey",
"house-02": "REP:Eugene F. Douglass|DEM:Deborah K. Ross|LIB:Matthew Laszacs",
"house-03": "LIB:Daniel Cavender|DEM:Raymond Smith|REP:Greg Murphy",
"house-04": "DEM:Valerie P. Foushee|LIB:Guy Meilleur|REP:Mahesh (Max) Ganorkar",
"house-05": "REP:Virginia Foxx|LIB:Robert B. Luffman|DEM:Chuck Hubbard",
"house-06": "REP:Addison McDowell|DEM:Cyril Jefferson",
"house-07": "LIB:Maad Abu-Ghazalah|REP:David Rouzer|DEM:Kimberly Hardy",
"house-08": "GRE:Bo Whitehead|DEM:Colby Watson|REP:Mark Harris",
"house-09": "DEM:Richard N. Ojeda II|REP:Richard Hudson",
"house-10": "LIB:Steven Feldman|DEM:Ashley Bell|REP:Pat Harrigan",
"house-11": "REP:Jennifer Capps Balkcom|DEM:Jamie Ager|LIB:Travis Groo",
"house-12": "REP:Jack Codiga|DEM:Alma S. Adams",
"house-13": "DEM:Paul Barringer|LIB:Steven Swinton|REP:Brad Knott",
"house-14": "DEM:Lakesha Womack|REP:Tim Moore",
}
SEATS = dict(zip("AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY".split(),
[7, 1, 9, 4, 52, 8, 5, 1, 28, 14, 2, 2, 17, 9, 4, 4, 6, 6, 2, 8, 9, 13, 8, 4, 8, 2, 3, 4, 2, 12, 3, 26, 14, 1, 15, 5, 6, 17, 2, 7, 1, 9, 38, 4, 1, 11, 10, 2, 8, 1]))
def write(path, value):
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n")
def main():
catalog = {"schema_version": 1, "kind": "catalog", "id": "nc-2026-10-04", "mode": "real",
"label": "North Carolina official list · partial US catalog", "as_of": SOURCE["retrieved_at"], "source": SOURCE,
"complete": False, "races": [], "candidates": [],
"notes": "Reviewed from official PDF pages 1–3, report dated 2026-09-21. Retrieval has day precision; midnight is a date placeholder. Federal contests only; NC has no scheduled 2026 governor race. Refresh against official changes before freezing."}
for contest, candidates in CONTESTS.items():
race = "2026-nc-" + contest + "-general"
catalog["races"].append({"id": race, "state": "NC", "office": contest.split("-")[0], "round": "general", "name": "North Carolina " + contest.replace("-", " ").title()})
for row in candidates.split("|"):
party, name = row.split(":", 1)
candidate_id = race + "-" + re.sub("[^a-z0-9]+", "-", name.lower()).strip("-")
catalog["candidates"].append({"id": candidate_id, "race_id": race, "name": name, "party": party,
"affiliation": {"DEM": "democrat", "REP": "republican"}.get(party, "other"), "source": SOURCE, "qualified": True, "ballot_lines": [party], "provider_ids": {}})
write(ROOT / "data/real/nc-2026-10-04.json", catalog)
combined = json.loads((ROOT / "data/real/mi-2026-10-04.json").read_text())
combined.update(id="us-2026-10-04-partial", label="Michigan + North Carolina · partial US catalog",
notes="Combined reviewed official lists. Each candidacy retains its original source and ID. Retrieval timestamps have day precision. 48 states still need imports; no live result feed is verified.",
sources=[combined["source"], SOURCE])
combined["races"] += catalog["races"]
combined["candidates"] += catalog["candidates"]
write(ROOT / "data/real/us-2026-10-04-partial.json", combined)
rows = list(csv.DictReader((ROOT / "data/coverage_2026.csv").open()))
inventory = []
for row in rows:
if row["state"] == "NC" and row["office"] in ("house", "senate"):
row["catalog"] = "official_partial"
if row["scheduled_2026"] != "yes":
continue
state, office = row["state"], row["office"]
for district in range(1, SEATS[state] + 1) if office == "house" else [None]:
seat = f"-{district:02}" if district else ""
special = office == "senate" and state in ("FL", "OH")
inventory.append({"id": f"2026-{state.lower()}-{office}{seat}-{'special' if special else 'general'}",
"state": state, "office": office, "district": district, "election_date": "2026-11-03", "contest_type": "special" if special else "general", "scoring_round": "november_first"})
with (ROOT / "data/coverage_2026.csv").open("w", newline="") as file:
writer = csv.DictWriter(file, fieldnames=rows[0].keys(), lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
assert len(inventory) == 506, len(inventory)
write(ROOT / "data/contests_2026.json", {"reviewed_at": "2026-10-04", "scope": "50 states; 435 House, 35 Senate (including FL/OH special), 36 governor; November contests only",
"sources": ["https://www.census.gov/data/tables/2020/dec/2020-apportionment-data.html", "https://www.senate.gov/senators/Class_II.htm", "https://about.usps.com/what/government-services/election-mail/pdf/2026-election-mail-kit.pdf"], "contests": inventory})
lines = ['#pragma once', '#include <array>', '#include <string_view>', '', 'namespace fpol {', 'inline constexpr auto national_races = std::array{']
lines += [' std::string_view{"' + race["id"] + '"},' for race in inventory]
lines += ['};', '}']
(ROOT / "src/national_races.hpp").write_text("\n".join(lines) + "\n")
if __name__ == "__main__":
main()