Game Features
ci / frontend (push) Successful in 9m13s
ci / backend (push) Failing after 6m40s

This commit is contained in:
khodowany
2026-10-04 23:20:06 -05:00
parent 2759337d16
commit 78b2c383f1
65 changed files with 12650 additions and 563 deletions
+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"};
}