#include "ChallengeRoutes.h" #include "LiveEventRoutes.h" #include "LiveWorld.h" #include "DashboardRoutes.h" #include "ClientAssets.h" #include "GameLabels.h" #include #include #include #include #include "RouteUtils.h" #include "WSRoutes.h" #include "Alerts.h" #include "Database.h" #include "CDClientDatabase.h" #include "Game.h" #include "dServer.h" #include "Logger.h" #include "dConfig.h" #include "MailInfo.h" #include "LiveOpsRules.h" #include "StatisticID.h" #include "IEconomyLedger.h" #include "eHTTPMethod.h" #include "magic_enum.hpp" using namespace RouteUtils; namespace { using Challenge = ILiveOps::Challenge; using eState = ILiveOps::eChallengeState; using eMetric = ILiveOps::eChallengeMetric; using LiveOpsRules::ePhase; constexpr auto CHECK_INTERVAL = std::chrono::seconds(10); constexpr size_t MAX_TITLE = 100; constexpr size_t MAX_DESCRIPTION = 400; constexpr size_t MAX_REWARD_ITEMS = 5; constexpr int64_t MAX_TARGET = 1'000'000'000'000; std::chrono::steady_clock::time_point g_NextCheck{}; int64_t Now() { return static_cast(std::time(nullptr)); } std::string ZoneList(const std::vector& zones) { if (zones.empty()) return "every zone"; std::string text; const auto& names = ZoneNames(); for (const auto zone : zones) { const auto key = std::to_string(zone); text += (text.empty() ? "" : ", ") + (names.contains(key) ? names[key].get() : "zone " + key); } return text; } // "Enemy Kills", "Quick Builds Completed" (with the LOT's name when limited to one) std::string MetricName(const Challenge& challenge) { std::string name; if (challenge.metricKind == eMetric::STATISTIC) { const auto stat = magic_enum::enum_cast(static_cast(challenge.metric)); name = stat ? GameLabels::Name(*stat) : "Statistic " + std::to_string(challenge.metric); } else { const auto kind = magic_enum::enum_cast(static_cast(challenge.metric)); name = kind ? GameLabels::Name(*kind) : "Map event " + std::to_string(challenge.metric); } if (challenge.lot != 0) name += " (" + ClientAssets::ObjectName(challenge.lot).value_or("LOT " + std::to_string(challenge.lot)) + ")"; return name; } const std::vector& Milestones() { static std::string lastText; static std::vector milestones = LiveOpsRules::DefaultMilestones(); const auto text = Game::config ? Game::config->GetValue("challenge_milestones") : ""; if (text != lastText) { lastText = text; milestones = LiveOpsRules::ParseMilestones(text); } return milestones; } ePhase PhaseOf(const Challenge& challenge, int64_t now) { return LiveOpsRules::Phase(static_cast(challenge.state), challenge.startsAt, challenge.endsAt, now); } nlohmann::json RewardItems(const Challenge& challenge) { auto items = nlohmann::json::parse(challenge.rewardItems, nullptr, false); return items.is_array() ? items : nlohmann::json::array(); } std::string RewardText(const Challenge& challenge) { std::string text; if (challenge.rewardCoins > 0) text = std::to_string(challenge.rewardCoins) + " coins"; for (const auto& item : RewardItems(challenge)) { const auto lot = item.value("lot", 0); text += (text.empty() ? "" : ", ") + std::to_string(item.value("count", 1)) + "x " + ClientAssets::ItemName(lot); } return text; } nlohmann::json ChallengeJson(const Challenge& challenge, const ILiveOps::ChallengeTotal& total, int64_t now) { return { {"id", challenge.id}, {"title", challenge.title}, {"description", challenge.description}, {"metricKind", challenge.metricKind == eMetric::STATISTIC ? "stat" : "map"}, {"metric", challenge.metric}, {"lot", challenge.lot}, {"metricName", MetricName(challenge)}, {"zones", challenge.zones}, {"zoneNames", ZoneList(challenge.zones)}, {"target", challenge.target}, {"total", total.total}, {"contributors", total.contributors}, {"percent", LiveOpsRules::Percent(total.total, challenge.target)}, {"startsAt", challenge.startsAt}, {"endsAt", challenge.endsAt}, {"phase", LiveOpsRules::PhaseName(PhaseOf(challenge, now))}, {"includeStaff", challenge.includeStaff}, {"isPublic", challenge.isPublic}, {"rewardCoins", challenge.rewardCoins}, {"rewardItems", RewardItems(challenge)}, {"rewardText", RewardText(challenge)}, {"rewardMin", challenge.rewardMin}, {"milestone", challenge.milestone}, {"completedAt", challenge.completedAt}, {"rewardedAt", challenge.rewardedAt}, {"rewardedCount", challenge.rewardedCount}, {"createdBy", challenge.createdBy}, {"createdAt", challenge.createdAt} }; } std::string Describe(const Challenge& challenge) { return "#" + std::to_string(challenge.id) + " \"" + challenge.title + "\": " + std::to_string(challenge.target) + " " + MetricName(challenge) + " in " + ZoneList(challenge.zones); } void Save(Challenge& challenge, const std::string& by) { challenge.updatedAt = Now(); challenge.updatedBy = by; Database::Get()->UpdateChallenge(challenge); BroadcastTableChanged("challenges", std::to_string(challenge.id)); } std::string Clip(const std::string& text, size_t length) { return text.size() <= length ? text : text.substr(0, length - 3) + "..."; } /** * Hand out a completed challenge's rewards: a reward row per character that contributed at least reward_min, its * items by mail (one mail per kind of item) and, for coins, a mail saying they wait in game. Characters already * rewarded are skipped, so running this again after an interruption gives nobody anything twice. */ void GiveRewards(Challenge& challenge) { const auto now = Now(); std::vector> contributions; for (const auto& row : Database::Get()->GetChallengeContributions(challenge.id, 0)) contributions.emplace_back(row.characterId, row.amount); std::vector rewarded; for (const auto& row : Database::Get()->GetChallengeRewards(challenge.id)) rewarded.push_back(row.characterId); const auto items = RewardItems(challenge); const bool anyReward = challenge.rewardCoins > 0 || !items.empty(); uint32_t count = 0; for (const auto& recipient : LiveOpsRules::RewardRecipients(contributions, challenge.rewardMin, rewarded)) { const auto characterId = recipient.first; const auto amount = recipient.second; const auto info = Database::Get()->GetCharacterInfo(characterId); if (!info) continue; // deleted since Database::Get()->InsertChallengeReward({ challenge.id, characterId, amount, challenge.rewardCoins, now, challenge.rewardCoins > 0 ? 0 : now }); count++; if (!anyReward) continue; const auto send = [&](LOT lot, int16_t itemCount, const std::string& body) { MailInfo mail; mail.senderId = LWOOBJID_EMPTY; mail.senderUsername = "Community Challenge"; mail.receiverId = characterId; mail.recipient = info->name; mail.subject = Clip("Challenge complete: " + challenge.title, 50); mail.body = Clip(body, 400); mail.timeSent = static_cast(now); mail.itemID = LWOOBJID_EMPTY; mail.itemSubkey = LWOOBJID_EMPTY; mail.itemLOT = lot; mail.itemCount = lot > 0 ? itemCount : 0; Database::Get()->InsertNewMail(mail); }; const std::string thanks = "Together we reached " + std::to_string(challenge.target) + " " + MetricName(challenge) + ", and you added " + std::to_string(amount) + ". Thank you!"; const std::string coins = challenge.rewardCoins > 0 ? " Your " + std::to_string(challenge.rewardCoins) + " coins are given to you in game (type /challenge if you don't see them)." : ""; if (items.empty()) { send(0, 0, thanks + coins); } else { for (size_t i = 0; i < items.size(); i++) send(items[i].value("lot", 0), static_cast(items[i].value("count", 1)), thanks + (i == 0 ? coins : "")); } } challenge.rewardedAt = now; challenge.rewardedCount += count; Save(challenge, "[system]"); Audit(SystemContext(), "challenge_rewards", Describe(challenge) + ": rewarded " + std::to_string(count) + " characters" + (anyReward ? " with " + RewardText(challenge) : "") + " (at least " + std::to_string(challenge.rewardMin) + " each)"); if (count > 0) BroadcastTableChanged("mail"); } /** * Tell players in game that a challenge is complete or over. It only goes out while the dashboard is connected to * the worlds (not in the first moments after it starts); end_announced_at records that it did, and Update() tries * again until it does (LiveOpsRules::EndAnnouncementDue), so nobody is told twice or never. Save afterwards. */ void AnnounceEnd(Challenge& challenge, int64_t total) { bool sent = false; if (challenge.state == eState::COMPLETED) { const auto reward = RewardText(challenge); sent = LiveWorld::Announce("Challenge complete!", challenge.title + ": " + std::to_string(total) + " " + MetricName(challenge) + "!" + (reward.empty() ? " Thank you, everyone!" : " Everyone who helped gets " + reward + ".")); } else if (challenge.state == eState::EXPIRED) { sent = LiveWorld::Announce("Challenge over", challenge.title + " ended at " + std::to_string(LiveOpsRules::Percent(total, challenge.target)) + "% (" + std::to_string(total) + " of " + std::to_string(challenge.target) + "). Thanks for trying!"); } if (sent) challenge.endAnnouncedAt = Now(); else LOG("Challenge %llu ended while the worlds can't be reached; it is announced once they can", static_cast(challenge.id)); } // When a challenge ended, for EndAnnouncementDue: an expired one is last saved when it expires int64_t EndedAt(const Challenge& challenge) { return challenge.state == eState::COMPLETED ? challenge.completedAt : challenge.updatedAt; } void Complete(Challenge& challenge, int64_t total) { challenge.state = eState::COMPLETED; challenge.completedAt = Now(); challenge.milestone = 100; AnnounceEnd(challenge, total); Save(challenge, "[system]"); Audit(SystemContext(), "complete_challenge", Describe(challenge) + " with " + std::to_string(total)); Alerts::Emit("server", "Community challenge complete", challenge.title + ": " + std::to_string(total) + " of " + std::to_string(challenge.target), {}, "/challenges"); GiveRewards(challenge); } void Check(Challenge& challenge, const ILiveOps::ChallengeTotal& total, int64_t now) { if (now < challenge.startsAt) return; if (total.total >= challenge.target) { Complete(challenge, total.total); LiveEventRoutes::ReloadWorlds(); // stop counting, and hand out the coins to whoever is online return; } if (now >= challenge.endsAt) { challenge.state = eState::EXPIRED; AnnounceEnd(challenge, total.total); Save(challenge, "[system]"); Audit(SystemContext(), "expire_challenge", Describe(challenge) + " at " + std::to_string(total.total)); LiveEventRoutes::ReloadWorlds(); return; } if (const auto milestone = LiveOpsRules::NextMilestone(Milestones(), challenge.milestone, total.total, challenge.target); milestone && *milestone < 100) { // Recorded only once it went out, so a milestone passed while the worlds can't be reached is announced later if (LiveWorld::Announce("Community challenge", challenge.title + " is " + std::to_string(*milestone) + "% done: " + std::to_string(total.total) + " of " + std::to_string(challenge.target) + " " + MetricName(challenge) + ". Type /challenge to see your part!")) { challenge.milestone = *milestone; Save(challenge, "[system]"); } } } // Metrics to pick from, straight from the server's enums nlohmann::json MetricOptions() { std::set recorded; try { const auto today = static_cast(Now() / 86400); for (const auto& row : Database::Get()->GetPlayerStatsPerDay(today - 90, today, false, {})) recorded.insert(row.value("stat", 0)); } catch (const std::exception&) {} nlohmann::json stats = nlohmann::json::array(); for (const auto stat : magic_enum::enum_values()) { stats.push_back({ {"value", static_cast(stat)}, {"name", GameLabels::Name(stat)}, {"recorded", recorded.contains(static_cast(stat))} }); } nlohmann::json events = nlohmann::json::array(); for (const auto kind : magic_enum::enum_values()) events.push_back({ {"value", static_cast(kind)}, {"name", GameLabels::Name(kind)} }); return { {"stats", stats}, {"mapEvents", events} }; } nlohmann::json ZoneOptions() { std::vector> zones; for (const auto& [id, name] : ZoneNames().items()) { if (const auto zone = GeneralUtils::TryParse(id); zone && *zone > 0) zones.emplace_back(*zone, name.get()); } std::sort(zones.begin(), zones.end()); nlohmann::json options = nlohmann::json::array(); for (const auto& [id, name] : zones) options.push_back({ {"id", id}, {"name", name} }); return options; } // A challenge from a request body into challenge (metric only when `metric`); error says what is wrong bool ReadBody(const nlohmann::json& body, Challenge& challenge, bool metric, std::string& error) { const auto fail = [&error](const std::string& message) { error = message; return false; }; challenge.title = body.value("title", std::string{}); challenge.description = body.value("description", std::string{}); if (challenge.title.empty() || challenge.title.size() > MAX_TITLE) return fail("The title is needed (up to 100 characters)"); if (challenge.description.size() > MAX_DESCRIPTION) return fail("The description is up to 400 characters"); if (metric) { const auto kind = body.value("metricKind", std::string{ "stat" }); const auto value = body.value("metric", -1); if (kind == "stat" && magic_enum::enum_cast(value)) challenge.metricKind = eMetric::STATISTIC; else if (kind == "map" && value >= 0 && value <= 255 && magic_enum::enum_cast(static_cast(value))) challenge.metricKind = eMetric::MAP_EVENT; else return fail("Pick what to count from the list"); challenge.metric = static_cast(value); challenge.lot = challenge.metricKind == eMetric::MAP_EVENT ? body.value("lot", 0) : 0; if (challenge.lot < 0 || (challenge.lot > 0 && !ClientAssets::ObjectName(challenge.lot))) return fail("Unknown LOT"); challenge.zones.clear(); if (body.contains("zones") && body["zones"].is_array()) { const auto& known = ZoneNames(); for (const auto& zone : body["zones"]) { if (!zone.is_number_unsigned() || !known.contains(std::to_string(zone.get()))) return fail("Pick zones from the list"); if (std::find(challenge.zones.begin(), challenge.zones.end(), zone.get()) == challenge.zones.end()) challenge.zones.push_back(zone.get()); } } challenge.includeStaff = body.value("includeStaff", false); } challenge.target = body.value("target", int64_t{ 0 }); if (challenge.target < 1 || challenge.target > MAX_TARGET) return fail("The target is a number from 1 up"); challenge.startsAt = body.value("startsAt", int64_t{ 0 }); challenge.endsAt = body.value("endsAt", int64_t{ 0 }); if (challenge.startsAt <= 0) challenge.startsAt = Now(); if (challenge.endsAt <= challenge.startsAt + 60 || challenge.endsAt - challenge.startsAt > 366 * 86400) return fail("It ends between a minute and a year after it starts"); if (challenge.endsAt <= Now()) return fail("The end is in the past"); challenge.isPublic = body.value("isPublic", true); challenge.rewardCoins = body.value("rewardCoins", int64_t{ 0 }); if (challenge.rewardCoins < 0 || challenge.rewardCoins > 1'000'000) return fail("Coins: 0 to 1000000"); challenge.rewardMin = body.value("rewardMin", int64_t{ 1 }); if (challenge.rewardMin < 1) return fail("A reward needs at least 1 contributed"); nlohmann::json items = nlohmann::json::array(); if (body.contains("rewardItems") && body["rewardItems"].is_array()) { for (const auto& item : body["rewardItems"]) { const auto lot = item.is_object() ? item.value("lot", 0) : 0; const auto count = item.is_object() ? item.value("count", 1) : 0; if (lot <= 0 || !ClientAssets::ObjectName(lot)) return fail("Pick reward items from the list"); if (count < 1 || count > 999) return fail("Item counts go from 1 to 999"); items.push_back({ {"lot", lot}, {"count", count} }); } } if (items.size() > MAX_REWARD_ITEMS) return fail("Up to 5 kinds of reward item"); challenge.rewardItems = items.dump(); return true; } std::optional FromPath(const HTTPContext& context, HTTPReply& reply) { const auto id = PathId(context.path, 2); auto challenge = id ? Database::Get()->GetChallenge(*id) : std::nullopt; if (!challenge) JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Challenge not found"); return challenge; } } namespace ChallengeRoutes { void Update() { const auto steady = std::chrono::steady_clock::now(); if (steady < g_NextCheck) return; g_NextCheck = steady + CHECK_INTERVAL; try { const auto now = Now(); auto open = Database::Get()->GetChallenges(true); std::vector ids; for (const auto& challenge : open) ids.push_back(challenge.id); const auto totals = Database::Get()->GetChallengeTotals(ids); for (auto& challenge : open) { const auto it = totals.find(challenge.id); Check(challenge, it == totals.end() ? ILiveOps::ChallengeTotal{} : it->second, now); } std::vector unannounced; for (auto& challenge : Database::Get()->GetChallenges(false)) { // A completed challenge whose rewards were interrupted if (challenge.state == eState::COMPLETED && challenge.rewardedAt == 0) GiveRewards(challenge); // One that ended while the worlds couldn't be told (e.g. the dashboard was restarting) if (LiveOpsRules::EndAnnouncementDue(static_cast(challenge.state), challenge.endAnnouncedAt, EndedAt(challenge), now)) unannounced.push_back(challenge); } if (!unannounced.empty()) { std::vector unannouncedIds; for (const auto& challenge : unannounced) unannouncedIds.push_back(challenge.id); const auto endTotals = Database::Get()->GetChallengeTotals(unannouncedIds); for (auto& challenge : unannounced) { if (!Game::server || !Game::server->GetIsConnectedToMaster()) break; const auto it = endTotals.find(challenge.id); // Fresh from the database: GiveRewards above may have saved it since the list was read if (auto current = Database::Get()->GetChallenge(challenge.id); current && current->endAnnouncedAt == 0) { AnnounceEnd(*current, it == endTotals.end() ? 0 : it->second.total); if (current->endAnnouncedAt != 0) Save(*current, "[system]"); } } } } catch (const std::exception& ex) { LOG("Challenges check failed: %s", ex.what()); } } nlohmann::json PublicJson() { nlohmann::json rows = nlohmann::json::array(); const auto now = Now(); const auto challenges = Database::Get()->GetChallenges(false); std::vector ids; for (const auto& challenge : challenges) ids.push_back(challenge.id); const auto totals = Database::Get()->GetChallengeTotals(ids); for (const auto& challenge : challenges) { const auto phase = PhaseOf(challenge, now); if (!challenge.isPublic) continue; if (phase != ePhase::ACTIVE && !(phase == ePhase::COMPLETED && now - challenge.completedAt < 7 * 86400)) continue; const auto it = totals.find(challenge.id); const auto total = it == totals.end() ? 0 : it->second.total; rows.push_back({ {"title", challenge.title}, {"description", challenge.description}, {"counting", MetricName(challenge)}, {"total", total}, {"target", challenge.target}, {"percent", LiveOpsRules::Percent(total, challenge.target)}, {"ends_at", challenge.endsAt}, {"completed", phase == ePhase::COMPLETED} }); } return rows; } void RegisterRoutes() { Route(eHTTPMethod::GET, "/challenges", Perm("challenges_view"), "Community challenges page", [](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "challenges.jinja2", "challenges"); }); Route(eHTTPMethod::GET, "/api/challenges", Perm("challenges_view"), "Community challenges with their progress and what your own characters added; for staff who manage them, the metrics and zones to pick", [](HTTPReply& reply, const HTTPContext& context) { const auto now = Now(); auto challenges = Database::Get()->GetChallenges(false); if (challenges.size() > 60) challenges.resize(60); std::vector ids; for (const auto& challenge : challenges) ids.push_back(challenge.id); const auto totals = Database::Get()->GetChallengeTotals(ids); // What the viewer's own characters added std::map mine; for (const auto& character : Database::Get()->GetAccountCharacters(context.accountId)) { const auto id = GeneralUtils::TryParse(character.value("id", std::string{})); if (!id) continue; for (const auto& [challengeId, amount] : Database::Get()->GetCharacterContributions(*id)) { if (amount > 0) mine[challengeId].push_back({ {"name", character.value("name", std::string{})}, {"amount", amount} }); } } const bool manage = Can(context, "challenges_manage"); nlohmann::json rows = nlohmann::json::array(); for (const auto& challenge : challenges) { if (!manage && PhaseOf(challenge, now) == ePhase::CANCELLED) continue; const auto it = totals.find(challenge.id); auto row = ChallengeJson(challenge, it == totals.end() ? ILiveOps::ChallengeTotal{} : it->second, now); row["mine"] = mine.contains(challenge.id) ? mine[challenge.id] : nlohmann::json::array(); rows.push_back(std::move(row)); } nlohmann::json result{ {"challenges", rows}, {"now", now}, {"canManage", manage} }; if (manage) { result["metrics"] = MetricOptions(); result["zones"] = ZoneOptions(); result["milestones"] = Milestones(); } JsonSuccess(reply, result); }); Route(eHTTPMethod::GET, "/api/challenges/objects", Perm("challenges_manage"), "CDClient objects to pick for a challenge: reward items (kind=item) or the object a map event counts (kind=any). Query: kind, q (name or LOT)", [](HTTPReply& reply, const HTTPContext& context) { const bool items = QueryValue(context.queryString, "kind") == "item"; const auto query = QueryValue(context.queryString, "q"); auto stmt = CDClientDatabase::CreatePreppedStmt(std::string("SELECT id, name, displayName, type FROM Objects WHERE ") + (items ? "type = 'Loot' AND " : "") + "(? = '' OR name LIKE '%' || ? || '%' OR displayName LIKE '%' || ? || '%' OR id = ?) ORDER BY name LIMIT 60;"); stmt.bind(1, query.c_str()); stmt.bind(2, query.c_str()); stmt.bind(3, query.c_str()); stmt.bind(4, GeneralUtils::TryParse(query).value_or(-1)); auto result = stmt.execQuery(); nlohmann::json objects = nlohmann::json::array(); for (; !result.eof(); result.nextRow()) { const std::string displayName = result.getStringField("displayName", ""); objects.push_back({ {"lot", result.getIntField("id")}, {"name", displayName.empty() ? result.getStringField("name", "") : displayName}, {"type", result.getStringField("type", "")} }); } JsonSuccess(reply, { {"objects", objects} }); }); Route(eHTTPMethod::GET, "/api/challenges/:id", Perm("challenges_view"), "One challenge's top contributors (and for staff, who was rewarded)", [](HTTPReply& reply, const HTTPContext& context) { const auto challenge = FromPath(context, reply); if (!challenge) return; nlohmann::json top = nlohmann::json::array(); for (const auto& row : Database::Get()->GetChallengeContributions(challenge->id, 20)) { top.push_back({ {"characterId", std::to_string(row.characterId)}, {"name", row.name}, {"amount", row.amount}, {"eligible", LiveOpsRules::Eligible(row.amount, challenge->rewardMin)} }); } nlohmann::json result{ {"top", top} }; if (Can(context, "challenges_manage")) { uint32_t waiting = 0; const auto rewards = Database::Get()->GetChallengeRewards(challenge->id); for (const auto& reward : rewards) if (reward.claimedAt == 0 && reward.coins > 0) waiting++; result["rewards"] = { {"count", rewards.size()}, {"coinsWaiting", waiting} }; } JsonSuccess(reply, result); }); Route(eHTTPMethod::POST, "/api/challenges", Perm("challenges_manage"), "Create a challenge. Body: {title, description, metricKind: stat|map, metric (StatisticID or eMapEvent value), lot (map events, 0: any), " "zones: [zone IDs] (empty: all), target, startsAt, endsAt (unix), includeStaff, isPublic, rewardCoins, rewardItems: [{lot, count}], rewardMin}", [](HTTPReply& reply, const HTTPContext& context) { const auto body = ParseBody(context); if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); Challenge challenge; std::string error; if (!ReadBody(*body, challenge, true, error)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, error); challenge.createdAt = challenge.updatedAt = Now(); challenge.createdBy = challenge.updatedBy = context.authenticatedUser; challenge.id = Database::Get()->InsertChallenge(challenge); Audit(context, "create_challenge", Describe(challenge) + ", reward: " + (RewardText(challenge).empty() ? "none" : RewardText(challenge))); LiveEventRoutes::ReloadWorlds(); if (challenge.startsAt <= Now()) { LiveWorld::Announce("New community challenge", challenge.title + ": " + std::to_string(challenge.target) + " " + MetricName(challenge) + " together! " + (challenge.description.empty() ? "" : challenge.description + " ") + "Type /challenge to follow it."); } BroadcastTableChanged("challenges", std::to_string(challenge.id)); JsonSuccess(reply, { {"message", "Challenge created"}, {"id", challenge.id} }); }); Route(eHTTPMethod::POST, "/api/challenges/:id", Perm("challenges_manage"), "Change an open challenge's text, target, times, visibility and rewards (what it counts stays). Body as when creating one", [](HTTPReply& reply, const HTTPContext& context) { auto challenge = FromPath(context, reply); if (!challenge) return; if (challenge->state != eState::OPEN) return JsonError(reply, eHTTPStatusCode::CONFLICT, "Only open challenges can be changed"); const auto body = ParseBody(context); if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); std::string error; if (!ReadBody(*body, *challenge, false, error)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, error); Save(*challenge, context.authenticatedUser); Audit(context, "update_challenge", Describe(*challenge)); LiveEventRoutes::ReloadWorlds(); JsonSuccess(reply, { {"message", "Saved"} }); }); Route(eHTTPMethod::POST, "/api/challenges/:id/cancel", Perm("challenges_manage"), "Stop an open challenge without rewards", [](HTTPReply& reply, const HTTPContext& context) { auto challenge = FromPath(context, reply); if (!challenge) return; if (challenge->state != eState::OPEN) return JsonError(reply, eHTTPStatusCode::CONFLICT, "It isn't open"); challenge->state = eState::CANCELLED; Save(*challenge, context.authenticatedUser); Audit(context, "cancel_challenge", Describe(*challenge)); LiveEventRoutes::ReloadWorlds(); JsonSuccess(reply, { {"message", "Cancelled"} }); }); Route(eHTTPMethod::POST, "/api/challenges/:id/delete", Perm("challenges_manage"), "Delete a challenge that is no longer open, with its contributions and reward records (mail already sent stays)", [](HTTPReply& reply, const HTTPContext& context) { const auto challenge = FromPath(context, reply); if (!challenge) return; if (challenge->state == eState::OPEN) return JsonError(reply, eHTTPStatusCode::CONFLICT, "Cancel it first"); if (challenge->state == eState::COMPLETED && challenge->rewardedAt == 0) return JsonError(reply, eHTTPStatusCode::CONFLICT, "Its rewards are still being handed out"); Database::Get()->DeleteChallenge(challenge->id); Audit(context, "delete_challenge", Describe(*challenge)); BroadcastTableChanged("challenges", std::to_string(challenge->id)); JsonSuccess(reply, { {"message", "Deleted"} }); }); } }