#include "application.hpp" #include "crypto.hpp" #include "results.hpp" #include "national_races.hpp" #include "domain.hpp" #include #include #include #include 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 { auto taken = std::set{}; auto filled = std::map>{}; 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>{}; auto needs = std::map>{}; 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{}; 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(teams.size()); auto position = pick % static_cast(teams.size()); if (round % 2 == 1) { position = static_cast(teams.size()) - 1 - position; } return teams.at(static_cast(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(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(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{}; 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::max(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{}; 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{}; 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(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"}; }