refactor(dashboard): reduce the CDClient browser to a raw table viewer

Drops the analysis views (objects, loot odds, missions, skills, behavior
trees, activities, zones), the name search and the 3D preview, with
their API routes and CDClientRules.h. What stays: the table list,
paging, sort, any-column search, column filters, and linked values that
open the target table filtered to that ID. Old links such as
/cdclient#/object/<LOT> (UGC page, world view) now open the Objects
table filtered to that LOT.

Check in the dashboard: CDClient Browser lists every table; open one,
sort by a column, add a filter, search, page; click a LOT or loot
matrix value; /cdclient#/object/1727 opens Objects id = 1727.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 00:20:58 -05:00
parent 266f8b81c5
commit 0a1e33d3d2
9 changed files with 48 additions and 2021 deletions

View File

@@ -109,7 +109,7 @@ namespace {
{ "permissions_manage", "Server", "Permissions", "Change what each GM level may do (always GM 9)", 9, true },
{ "dev_message_inspector", "Developer tools", "Game message inspector", "Capture the game messages an online player sends and receives, live (every capture is audited)", 8 },
{ "dev_cdclient", "Developer tools", "CDClient browser", "Browse the game's CDClient data: objects and their components, loot, missions, skills, behaviors and activities", 8 },
{ "dev_cdclient", "Developer tools", "CDClient browser", "Read the game's CDClient tables as they are: page, sort, search and filter any table", 8 },
};
const std::map<std::string, const Permissions::Permission*>& Index() {

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
#pragma once
/**
* The CDClient browser (developer tool): search and read the game's CDClient database. Any table can be paged and
* filtered by its columns, and objects, loot matrices and tables, missions, skills, behaviors and activities have
* their own views that follow the links between tables (see CDClientSchema.h). Read-only; the browser never sends SQL.
* The CDClient browser (developer tool): a raw viewer for the game's CDClient database. Any table can be paged, sorted,
* searched and filtered by its columns; columns that point at other tables (see CDClientSchema.h) link to the rows they
* point at. Read-only; the browser never sends SQL.
*/
void RegisterCDClientBrowserRoutes();

View File

@@ -1,271 +0,0 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <string_view>
#include <utility>
#include <vector>
#include "CDClientSchema.h"
#include "eMissionTaskType.h"
/**
* What the server makes of CDClient rows, for the CDClient browser: drop chances as dGame/dUtilities/Loot.cpp rolls
* them, vendor stock as VendorComponent fills it, mission prerequisites as MissionPrerequisites evaluates them and what
* a mission task's numbers are as MissionTask::Progress compares them. Each follows the server code it names, so when
* that code changes, this should too. Pure, so it is unit tested.
*/
namespace CDClientRules {
// ---- Loot ----
struct RarityRow {
double randmax{};
int32_t rarity{};
};
/**
* The chance of each rarity a drop rolls (RollLootMatrix). The rows are walked in the order the server loads them
* (randmax descending) and a roll in [0, 1) takes the rarity of the last row whose randmax is at or above it, so a
* row gets the span between its randmax and the next lower one. A roll above every row keeps the starting rarity 1.
*/
inline std::vector<std::pair<int32_t, double>> RarityChances(const std::vector<RarityRow>& rows) {
std::vector<std::pair<int32_t, double>> chances;
const auto add = [&](int32_t rarity, double chance) {
if (chance <= 0.0) return;
const auto it = std::ranges::find_if(chances, [&](const auto& c) { return c.first == rarity; });
if (it == chances.end()) chances.emplace_back(rarity, chance);
else it->second += chance;
};
if (rows.empty()) {
add(1, 1.0);
return chances;
}
add(1, 1.0 - std::clamp(rows.front().randmax, 0.0, 1.0));
for (size_t i = 0; i < rows.size(); i++) {
const double top = std::clamp(rows[i].randmax, 0.0, 1.0);
const double bottom = i + 1 < rows.size() ? std::clamp(rows[i + 1].randmax, 0.0, 1.0) : 0.0;
add(rows[i].rarity, top - bottom);
}
return chances;
}
/**
* Which entries of a loot table a drop picks from (one of them, evenly) for a rolled rarity, as RollLootMatrix
* does: the table is sorted by item rarity, highest first (CDLootTableTable), items above the rolled rarity are
* skipped, items of it are taken, and while none of it has been found a lower rarity is taken and becomes the rarity
* looked for. `rarities` are the items' rarities (0 without an item component) in any order; indexes into it.
*/
inline std::vector<size_t> Candidates(const std::vector<int32_t>& rarities, int32_t rolled) {
std::vector<size_t> order(rarities.size());
for (size_t i = 0; i < order.size(); i++) order[i] = i;
std::ranges::stable_sort(order, [&](size_t a, size_t b) { return rarities[a] > rarities[b]; });
std::vector<size_t> picked;
int32_t maxRarity = rolled;
bool found = false;
for (const auto index : order) {
const auto rarity = rarities[index];
if (rarity == maxRarity) {
picked.push_back(index);
found = true;
} else if (rarity < maxRarity && !found) {
picked.push_back(index);
maxRarity = rarity;
}
}
return picked;
}
// The chance that one drop from a loot table is each of its entries, given the rarity table it is rolled with
inline std::vector<double> ChancePerDrop(const std::vector<int32_t>& rarities, const std::vector<RarityRow>& rows) {
std::vector<double> chances(rarities.size(), 0.0);
for (const auto& [rarity, chance] : RarityChances(rows)) {
const auto picked = Candidates(rarities, rarity);
for (const auto index : picked) chances[index] += chance / picked.size();
}
return chances;
}
struct DropOdds {
double atLeastOne{}; // chance of getting it at least once
double expected{}; // how many on average
};
/**
* Odds for one item of a loot matrix entry: the entry rolls with `percent`, then drops a count between minDrops and
* maxDrops (evenly), each drop being the item with `perDrop`. Before any live event's loot bonus.
*/
inline DropOdds Odds(double percent, int32_t minDrops, int32_t maxDrops, double perDrop) {
percent = std::clamp(percent, 0.0, 1.0);
minDrops = std::max(minDrops, 0);
maxDrops = std::max(maxDrops, minDrops);
double missAll = 0.0;
const int32_t counts = maxDrops - minDrops + 1;
for (int32_t n = minDrops; n <= maxDrops; n++) missAll += std::pow(1.0 - perDrop, n) / counts;
return { percent * (1.0 - missAll), percent * (minDrops + maxDrops) / 2.0 * perDrop };
}
/**
* The chance a vendor has an entry's item for sale (VendorComponent::RefreshInventory): with minToDrop or
* maxToDrop 0 it sells the whole loot table, otherwise that many of its items at random (chance and rarity play no
* part), picked again every refresh.
*/
inline double VendorStockChance(int32_t minDrops, int32_t maxDrops, size_t tableSize) {
if (minDrops == 0 || maxDrops == 0) return 1.0;
if (tableSize == 0) return 0.0;
maxDrops = std::max(maxDrops, minDrops);
return std::min(1.0, (minDrops + maxDrops) / 2.0 / static_cast<double>(tableSize));
}
// ---- Missions ----
/**
* Missions.prereqMissionID as MissionPrerequisites reads it: a mission (with an optional ":state" it must be in,
* otherwise it must be complete), then "|" (or) or "," "&" "(" (and) joining it to all the rest, which is read the
* same way; ")" and spaces are skipped. So "1|2,3" is 1 or (2 and 3), and brackets don't group. A term without a
* mission (mission 0) is always met.
*/
struct PrerequisiteTerm {
uint32_t mission{};
uint32_t state{}; // 0: must be complete
bool orRest{}; // joined to the rest by "or" (otherwise "and"); the last term has no rest
};
inline std::vector<PrerequisiteTerm> ParsePrerequisites(std::string_view text) {
std::vector<PrerequisiteTerm> terms;
while (true) {
PrerequisiteTerm term;
bool inState = false, more = false;
size_t i = 0;
// Numbers too long for an ID are cut short rather than overflowing
const auto append = [](uint32_t& value, char c) { if (value < 100000000u) value = value * 10 + (c - '0'); };
for (; i < text.size(); i++) {
const char c = text[i];
if (c == '|' || c == ',' || c == '&' || c == '(') {
term.orRest = c == '|';
more = true;
break;
}
if (c == ':') inState = true;
else if (c >= '0' && c <= '9') append(inState ? term.state : term.mission, c);
}
if (!more) {
term.orRest = false;
terms.push_back(term);
break;
}
terms.push_back(term);
text.remove_prefix(i + 1);
}
// Terms without a mission are always met, so "and" ones change nothing ("(" starts one): leave them out
std::vector<PrerequisiteTerm> kept;
for (size_t i = 0; i < terms.size(); i++) {
const bool last = i + 1 == terms.size();
const bool drop = terms[i].mission == 0 && terms.size() > 1 && (last ? !kept.empty() && !kept.back().orRest : !terms[i].orRest);
if (!drop) kept.push_back(terms[i]);
}
if (!kept.empty()) kept.back().orRest = false;
return kept;
}
/**
* What a mission task's numbers are (MissionTask::Progress, and what the server passes to it): `target` and the
* comma separated targetGroup are compared with the value the task is progressed with, taskParam1's numbers with
* the value or the associate. Each list names what the numbers can be; empty means a number the browser can't link.
*/
struct TaskMeaning {
bool progressed{ true }; // false: Progress logs the type as invalid and never counts it
std::vector<CDClientSchema::eLink> target;
bool targetGroupIds{}; // targetGroup holds more IDs like target
bool targetGroupText{}; // targetGroup is compared as text (a point of interest, a score name)
bool targetUsed{ true }; // false: target plays no part
std::vector<CDClientSchema::eLink> parameters;
bool racingParameter{}; // taskParam1's first number is an eRacingTaskParam
};
inline TaskMeaning MeaningOf(eMissionTaskType type) {
using enum eMissionTaskType;
using Link = CDClientSchema::eLink;
TaskMeaning meaning;
switch (type) {
// Progressed with the LOT of what was smashed, scripted, collected, interacted with, tamed or gathered
case SMASH: case SCRIPT: case COLLECTION: case INTERACT: case PET_TAMING: case GATHER:
meaning.target = { Link::OBJECT };
meaning.targetGroupIds = true;
break;
// Only `target` is compared (GetTarget)
case TALK_TO_NPC: case USE_ITEM:
meaning.target = { Link::OBJECT };
break;
// The emote is in taskParam1; `target` is the LOT it was done at
case EMOTE:
meaning.target = { Link::OBJECT };
meaning.parameters = { Link::EMOTE };
break;
// The skill is in taskParam1; the targets are the LOT it hit
case USE_SKILL:
meaning.target = { Link::OBJECT };
meaning.targetGroupIds = true;
meaning.parameters = { Link::SKILL };
break;
// An activity ID, or the LOT of a quick build or cannon
case ACTIVITY:
meaning.target = { Link::ACTIVITY, Link::OBJECT };
meaning.targetGroupIds = true;
break;
// The minigame's activity ID (or its LOT without one); targetGroup names the score, targetValue is the least
case PERFORM_ACTIVITY:
meaning.target = { Link::ACTIVITY, Link::OBJECT };
meaning.targetGroupText = true;
break;
// Only the point of interest's name counts
case EXPLORE:
meaning.targetUsed = false;
meaning.targetGroupText = true;
break;
case META:
meaning.target = { Link::MISSION };
meaning.targetGroupIds = true;
break;
// Progressed with the skill ID of the powerup
case POWERUP:
meaning.target = { Link::SKILL };
meaning.targetGroupIds = true;
break;
// Player flag IDs
case PLAYER_FLAG:
// Progressed with 0 and the reputation earned as the count
case EARN_REPUTATION:
case VISIT_PROPERTY:
meaning.targetGroupIds = true;
break;
// Targets are zones, tracks or missions depending on the racing parameter
case RACING:
meaning.targetGroupIds = true;
meaning.racingParameter = true;
break;
// Any progress counts
case PLACE_MODEL: case ADD_BEHAVIOR: case DONATION:
meaning.targetUsed = false;
break;
default:
meaning.progressed = false;
meaning.targetUsed = false;
break;
}
return meaning;
}
// Numbers in a comma separated list, as MissionTask reads targetGroup and taskParam1 (anything else is skipped)
inline std::vector<int64_t> NumberList(std::string_view text) {
std::vector<int64_t> numbers;
while (!text.empty()) {
const auto comma = text.find(',');
// TryParse skips leading spaces but not trailing ones, so "1 ,2" loses the 1 on the server too
if (const auto number = GeneralUtils::TryParse<uint32_t>(text.substr(0, comma))) numbers.push_back(*number);
if (comma == std::string_view::npos) break;
text.remove_prefix(comma + 1);
}
return numbers;
}
}

View File

@@ -83,7 +83,7 @@ namespace CDClientSchema {
// ---- Links between tables ----
// What a column's values point at. The browser has a page for each.
// What a column's values point at. The browser links such values to the rows they point at.
enum class eLink : uint8_t { OBJECT, LOOT_MATRIX, LOOT_TABLE, RARITY_TABLE, BEHAVIOR, SKILL, MISSION, ACTIVITY, ICON, ZONE, EMOTE };
struct LinkTarget {
@@ -175,18 +175,7 @@ namespace CDClientSchema {
return std::nullopt;
}
// Every (table, column) that points at `link`, for "used by" lists
inline std::vector<std::pair<const Table*, const Column*>> ColumnsLinkingTo(const Schema& schema, eLink link) {
std::vector<std::pair<const Table*, const Column*>> columns;
for (const auto& [name, table] : schema.Tables()) {
for (const auto& column : table.columns) {
if (LinkFor(schema, table, column) == link) columns.emplace_back(&table, &column);
}
}
return columns;
}
// The link's name for the page ("loot_matrix")
// The link's name as the browser sends it ("loot_matrix")
inline std::string LinkName(eLink link) {
return Lower(magic_enum::enum_name(link));
}
@@ -372,24 +361,4 @@ namespace CDClientSchema {
if (name.find("faction") != std::string::npos) return false;
return name.starts_with("behavior") || name.starts_with("on_") || name.find("action") != std::string::npos;
}
/**
* Mission IDs in Missions.prereqMissionID, which lists them separated by commas, pipes, ampersands or spaces,
* sometimes with a ":state" suffix or in brackets ("(1,2)|3", "123:4").
*/
inline std::vector<int32_t> PrerequisiteMissions(std::string_view text) {
std::vector<int32_t> ids;
for (size_t i = 0; i < text.size();) {
if (!std::isdigit(static_cast<unsigned char>(text[i]))) { i++; continue; }
size_t end = i;
while (end < text.size() && std::isdigit(static_cast<unsigned char>(text[end]))) end++;
const bool isState = i > 0 && text[i - 1] == ':';
if (!isState && end - i <= 9) {
const auto id = static_cast<int32_t>(std::stol(std::string(text.substr(i, end - i))));
if (std::ranges::find(ids, id) == ids.end()) ids.push_back(id);
}
i = end;
}
return ids;
}
}

View File

@@ -1,33 +1,24 @@
/**
* The CDClient Browser page. Everything is addressed by the hash, so views can be linked and the back button works:
* #/ search and the list of tables
* #/search/<text> search results
* The CDClient Browser page: a raw table viewer. Everything is addressed by the hash, so views can be linked and the
* back button works:
* #/ the list of tables
* #/table/<Name>?... a table's rows (start, order, dir, q, filters as JSON [{column, op, value}])
* #/<link>/<id> object, mission, skill, behavior, activity, zone, loot_matrix, loot_table
* Which columns link where comes from /api/cdclient/schema; links without their own view open the target table
* filtered to that value.
* #/<link>/<id> shorthand for the target table filtered to that ID (#/object/1727 is Objects id = 1727)
* Which columns link where comes from /api/cdclient/schema; a linked value opens the target table filtered to it.
*/
(function () {
'use strict';
var VIEWS = ['object', 'mission', 'skill', 'behavior', 'activity', 'zone', 'loot_matrix', 'loot_table'];
var PAGE = 50;
var OPS = ['=', '!=', '<', '<=', '>', '>=', 'contains', 'starts', 'null', 'notnull'];
var nf = new Intl.NumberFormat();
var view = document.getElementById('view');
var schema = null;
var viewer = null;
function api404(d) { return d && d.success === false; }
// ---- links ----
function hashFor(link, id) {
if (VIEWS.indexOf(link) !== -1) return '#/' + link + '/' + id;
var target = schema && schema.links[link];
if (!target) return null;
return tableHash(target.table, { filters: [{ column: target.column, op: '=', value: String(id) }] });
}
function tableHash(table, params) {
var query = [];
Object.keys(params || {}).forEach(function (k) {
@@ -38,20 +29,15 @@
return '#/table/' + encodeURIComponent(table) + (query.length ? '?' + query.join('&') : '');
}
function refLink(ref) {
var href = hashFor(ref.link, ref.id);
var text = esc(ref.name) + ' <span class="text-body-secondary">' + esc(ref.id) + '</span>';
return href ? '<a href="' + href + '">' + text + '</a>' : text;
}
function chips(refs) {
if (!refs || !refs.length) return '<span class="text-body-secondary">None</span>';
return refs.map(function (r) { return '<span class="cdc-chip badge bg-body-tertiary text-body border">' + refLink(r) + '</span>'; }).join('');
// The rows a link points at: its target table filtered to the ID
function hashFor(link, id) {
var target = schema && schema.links[link];
if (!target) return null;
return tableHash(target.table, { filters: [{ column: target.column, op: '=', value: String(id) }] });
}
function componentTable(type) {
var match = schema.componentTypes.filter(function (c) { return c.type === type; })[0];
return match ? match : null;
return schema.componentTypes.filter(function (c) { return c.type === type; })[0] || null;
}
// One cell: linked when its column points somewhere
@@ -77,55 +63,8 @@
return text;
}
function columnsOf(table) {
var t = schema.tables.filter(function (x) { return x.name === table; })[0];
return t ? t.columns : [];
}
// A row as a two-column table
function keyValues(row, columns, table) {
columns = columns || Object.keys(row).map(function (k) { return { name: k }; });
return '<table class="table table-sm cdc-kv mb-0"><tbody>' + columns.map(function (c) {
return '<tr><th>' + esc(c.name) + (c.link ? ' <span class="badge bg-body-tertiary text-body border">' + esc(c.link) + '</span>' : '') + '</th><td>' + cell(row[c.name], c, row, table) + '</td></tr>';
}).join('') + '</tbody></table>';
}
// Rows as a table, with the schema's links
function rowsTable(rows, table, columns) {
if (!rows.length) return '<div class="text-body-secondary small">No rows.</div>';
columns = columns || (table ? columnsOf(table) : null) || [];
if (!columns.length) columns = Object.keys(rows[0]).map(function (k) { return { name: k }; });
return '<div class="cdc-rows-wrap"><table class="table table-sm table-hover cdc-rows mb-0"><thead><tr>' +
columns.map(function (c) { return '<th>' + esc(c.name) + '</th>'; }).join('') + '</tr></thead><tbody>' +
rows.map(function (row) {
return '<tr>' + columns.map(function (c) { return '<td>' + cell(row[c.name], c, row, table) + '</td>'; }).join('') + '</tr>';
}).join('') + '</tbody></table></div>';
}
function card(title, body, extra) {
return '<div class="card mb-3"><div class="card-header d-flex justify-content-between align-items-center"><h5 class="mb-0 fs-6">' + title + '</h5>' +
(extra || '') + '</div><div class="card-body">' + body + '</div></div>';
}
function localized(texts) {
var keys = Object.keys(texts || {});
if (!keys.length) return '';
return card('Texts (locale)', '<table class="table table-sm cdc-kv mb-0"><tbody>' + keys.map(function (k) {
return '<tr><th>' + esc(k) + '</th><td style="font-family: inherit">' + esc(texts[k]) + '</td></tr>';
}).join('') + '</tbody></table>');
}
// "Used by": everything pointing here, per table and column
function usedBy(references, link, id) {
if (!references || !references.length) return card('Used by', '<span class="text-body-secondary">Nothing in the CDClient points here.</span>');
return card('Used by', references.map(function (r) {
var href = tableHash(r.table, { filters: [{ column: r.column, op: '=', value: String(id) }] });
return '<details class="mb-2"><summary><a href="' + href + '">' + esc(r.table) + '.' + esc(r.column) + '</a> <span class="badge text-bg-secondary">' + nf.format(r.count) + '</span>' +
(r.objects && r.objects.length ? ' <span class="small text-body-secondary">on ' + r.objects.length + ' object(s)</span>' : '') + '</summary>' +
(r.objects && r.objects.length ? '<div class="my-2">' + chips(r.objects) + '</div>' : '') +
rowsTable(r.rows, r.table) + (r.count > r.rows.length ? '<div class="small text-body-secondary">First ' + r.rows.length + ' of ' + nf.format(r.count) + '; <a href="' + href + '">see all</a>.</div>' : '') +
'</details>';
}).join(''));
function card(title, body) {
return '<div class="card mb-3"><div class="card-header"><h5 class="mb-0 fs-6">' + title + '</h5></div><div class="card-body">' + body + '</div></div>';
}
function header(title, subtitle) {
@@ -136,10 +75,6 @@
view.innerHTML = '<div class="alert alert-warning">' + esc(message) + '</div>';
}
function disposeViewer() {
if (viewer) { viewer.dispose(); viewer = null; }
}
// ---- views ----
function home() {
@@ -149,29 +84,20 @@
'<div class="cdc-tables" id="tableLinks">' + tables.map(function (t) {
return '<a href="' + tableHash(t.name) + '" data-name="' + esc(t.name.toLowerCase()) + '">' + esc(t.name) + '</a>';
}).join('') + '</div>');
document.getElementById('tableFilter').addEventListener('input', function (e) {
var text = e.target.value.trim().toLowerCase();
var filter = document.getElementById('tableFilter');
filter.addEventListener('input', function () {
var text = filter.value.trim().toLowerCase();
document.querySelectorAll('#tableLinks a').forEach(function (a) { a.classList.toggle('d-none', text && a.dataset.name.indexOf(text) === -1); });
});
}
function search(text) {
document.getElementById('searchInput').value = text;
api.get('/api/cdclient/search?q=' + encodeURIComponent(text)).then(function (d) {
if (api404(d)) return showError(d.error);
var sections = [['Exact ID', d.exact], ['Objects', d.objects], ['Missions', d.missions], ['Skills', d.skills], ['Activities', d.activities], ['Zones', d.zones]];
var found = sections.filter(function (s) { return s[1].length; });
if (found.length === 1 && found[0][1].length === 1) { window.location.hash = hashFor(found[0][1][0].link, found[0][1][0].id); return; }
view.innerHTML = header('Results for "' + esc(text) + '"') + (found.length ? found.map(function (s) {
return card(s[0], '<div class="list-group list-group-flush">' + s[1].map(function (r) {
return '<div class="list-group-item px-0"><span class="badge bg-body-tertiary text-body border me-2">' + esc(r.link) + '</span>' + (r.link === 'object' ? item(r) : refLink(r)) + '</div>';
}).join('') + '</div>');
}).join('') : '<div class="text-body-secondary">Nothing found.</div>');
// Enter opens the only table left
filter.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
var shown = document.querySelectorAll('#tableLinks a:not(.d-none)');
if (shown.length === 1) window.location.hash = shown[0].getAttribute('href');
});
filter.focus();
}
var OPS = ['=', '!=', '<', '<=', '>', '>=', 'contains', 'starts', 'null', 'notnull'];
function tableView(name, params) {
var start = parseInt(params.start || '0', 10) || 0;
var filters = [];
@@ -193,7 +119,7 @@
var arrow = state.order === c.name ? (state.dir === 'desc' ? ' &darr;' : ' &uarr;') : '';
return '<th><a href="#" class="text-body text-decoration-none" data-order="' + esc(c.name) + '" title="' + esc(c.type) + (c.link ? ', links to ' + esc(c.link) : '') + '">' + esc(c.name) + arrow + '</a></th>';
}).join('') + '</tr>';
view.innerHTML = header(esc(name), nf.format(d.total) + ' matching rows') +
view.innerHTML = header('<a href="#/" class="text-decoration-none">Tables</a> / ' + esc(name), nf.format(d.total) + ' matching rows') +
'<div class="card mb-3"><div class="card-body">' +
'<form class="row g-2 align-items-end mb-2" id="filterForm">' +
'<div class="col-sm-3"><label class="form-label small mb-1" for="fColumn">Column</label><select class="form-select form-select-sm" id="fColumn">' + columnOptions + '</select></div>' +
@@ -231,454 +157,9 @@
});
}
// ---- game values ----
// A chance as a percentage with the digits that matter (0.4%, 12.5%, 100%)
function pct(chance) {
var p = (chance || 0) * 100;
if (p === 0) return '0%';
if (p >= 99.95) return '100%';
return (p >= 10 ? p.toFixed(1) : p >= 1 ? p.toFixed(2) : +p.toPrecision(2)) + '%';
}
function num(value) {
return typeof value === 'number' ? nf.format(+value.toPrecision(7)) : esc(value === null || value === undefined ? '' : value);
}
// Rarity as the game shows it: 1 to 4 stars, coloured
function rarity(value) {
if (!value || value < 1) return '';
return ' <span class="rarity-' + esc(value) + '" title="Rarity ' + esc(value) + '" aria-label="Rarity ' + esc(value) + '">' + '\u2605'.repeat(Math.min(value, 5)) + '</span>';
}
function icon(lot) {
return '<img class="cdc-icon" src="/api/icon/' + esc(lot) + '" alt="" loading="lazy" onerror="this.remove()">';
}
function iconById(id, size) {
if (!id || id <= 0) return '';
return '<img src="/api/icon_id/' + esc(id) + '" alt="" width="' + (size || 20) + '" height="' + (size || 20) + '" class="me-1 rounded" loading="lazy" onerror="this.remove()">';
}
// An object with its icon and rarity; other links as they are
function item(ref) {
if (!ref) return '<span class="text-body-secondary">-</span>';
if (ref.link !== 'object') return ref.link ? refLink(ref) : '<span class="font-monospace">' + esc(ref.id) + '</span>';
return '<a href="' + hashFor('object', ref.id) + '">' + icon(ref.id) + esc(ref.name) + '</a>' + rarity(ref.rarity) + ' <span class="text-body-secondary small">' + esc(ref.id) + '</span>';
}
function items(refs, count) {
if (!refs || !refs.length) return '<span class="text-body-secondary">None</span>';
return refs.map(function (r) { return '<div>' + item(r.item || r) + (count && r.count !== undefined ? ' &times; ' + num(r.count) : '') + '</div>'; }).join('');
}
function none(text) { return '<span class="text-body-secondary">' + (text || 'None') + '</span>'; }
function raw(title, body) {
return '<details class="mt-2"><summary class="small text-body-secondary">' + title + '</summary><div class="mt-2">' + body + '</div></details>';
}
// Like raw, but the body is only built when opened (loot lists can run to thousands of rows)
var lazy = {}, lazyCount = 0;
function later(title, render) {
lazy[++lazyCount] = render;
return '<details class="mt-2" data-lazy="' + lazyCount + '"><summary class="small text-body-secondary">' + title + '</summary><div class="mt-2"></div></details>';
}
view.addEventListener('toggle', function (e) {
var details = e.target;
if (!details.open || !details.dataset || !lazy[details.dataset.lazy]) return;
details.lastElementChild.innerHTML = lazy[details.dataset.lazy]();
delete lazy[details.dataset.lazy];
}, true);
function table(head, rows, cls) {
return '<div class="cdc-rows-wrap"><table class="table table-sm align-middle mb-0 ' + (cls || '') + '"><thead><tr>' + head.map(function (h) {
return '<th' + (h.num ? ' class="num"' : '') + '>' + h.text + '</th>';
}).join('') + '</tr></thead><tbody>' + rows.join('') + '</tbody></table></div>';
}
function stat(label, value) {
return '<div class="col"><div class="small text-body-secondary">' + label + '</div><div class="cdc-stat">' + value + '</div></div>';
}
// Mission tasks that count something
function countedBy(tasks) {
if (!tasks || !tasks.length) return '';
return card('Counted by mission tasks', tasks.map(function (t) {
return '<div>' + refLink(t.mission) + ' <span class="badge bg-body-tertiary text-body border">' + esc(t.taskType) + '</span>' +
(t.targetValue ? ' <span class="small text-body-secondary">&times; ' + num(t.targetValue) + '</span>' : '') + '</div>';
}).join(''));
}
/**
* A loot matrix as the server rolls it: each item's chance of dropping at least once and how many on average, and
* under that how each entry rolls. `per` names what one roll is (a smash, an opened package, an activity reward).
*/
function lootOdds(odds, per) {
if (!odds || !odds.entries || !odds.entries.length) return none('No loot matrix rows');
var rows = odds.items.map(function (i) {
return '<tr><td>' + item(i.item) + '</td><td class="num">' + pct(i.chance) + '</td><td class="num">' + num(+i.expected.toPrecision(3)) + '</td></tr>';
});
var shown = rows.length > 25 ? rows.slice(0, 25) : rows;
var html = table([{ text: 'Item' }, { text: 'Chance per ' + esc(per), num: true }, { text: 'Average count', num: true }], shown, 'cdc-odds');
if (rows.length > shown.length) html += later('All ' + rows.length + ' items', function () { return table([{ text: 'Item' }, { text: 'Chance', num: true }, { text: 'Average', num: true }], rows, 'cdc-odds'); });
html += later('How it rolls (' + odds.entries.length + (odds.entries.length === 1 ? ' entry' : ' entries') + ')', function () { return odds.entries.map(function (e) {
return '<div class="mb-3"><div><a href="' + hashFor('loot_table', e.lootTable) + '">Loot table ' + esc(e.lootTable) + '</a>: rolls with ' + pct(e.percent) + ', then ' +
(e.minToDrop === e.maxToDrop ? esc(e.minToDrop) : esc(e.minToDrop) + '&ndash;' + esc(e.maxToDrop)) + ' drop(s)' +
(e.flagID ? ' <span class="small text-body-secondary">(flagID ' + esc(e.flagID) + ' in the row; the server\'s roll doesn\'t check it)</span>' : '') + '</div>' +
'<div class="small text-body-secondary">Each drop rolls a rarity (<a href="' + hashFor('rarity_table', e.rarityTable) + '">rarity table ' + esc(e.rarityTable) + '</a>): ' +
e.rolledRarity.map(function (r) { return rarity(r.rarity).trim() + ' ' + pct(r.chance); }).join(', ') +
', then picks evenly among the items of that rarity (or the next lower one the table has).</div>' +
table([{ text: 'Item' }, { text: 'Per drop', num: true }, { text: 'Chance', num: true }, { text: '' }], e.items.map(function (i) {
return '<tr><td>' + item(i.item) + '</td><td class="num">' + pct(i.perDrop) + '</td><td class="num">' + pct(i.chance) + '</td><td>' +
(i.missionDrop ? '<span class="badge bg-body-tertiary text-body border" title="Dropped only while a mission needs it">mission item</span>' : '') + '</td></tr>';
}), 'cdc-odds') + '</div>';
}).join(''); }) + '<div class="small text-body-secondary mt-2">Chances before any live event loot bonus. Mission items drop only for players whose mission needs them.</div>';
return html;
}
// ---- views ----
function objectView(lot) {
api.get('/api/cdclient/objects/' + lot).then(function (d) {
if (api404(d)) return showError(d.error);
var sub = [esc(d.row.type || '')];
if (d.item) sub.push(esc(d.item.itemTypeName));
var html = header('<img src="/api/icon/' + lot + '" alt="" width="48" height="48" class="me-2 rounded" onerror="this.remove()">' + esc(d.name) +
(d.item ? rarity(d.item.rarity) : '') + ' <span class="text-body-secondary fs-5">' + lot + '</span>', sub.filter(Boolean).join(' &middot; ') +
(d.row._internalNotes ? ' &middot; ' + esc(d.row._internalNotes) : ''));
if (d.description) html += '<p class="text-body-secondary">' + esc(d.description) + '</p>';
var left = '', right = '';
if (d.item) left += itemCard(d.item);
if (d.destructible) left += destructibleCard(d.destructible);
d.loot.forEach(function (l) {
left += card('Drops (' + esc(l.table) + ', <a href="' + hashFor('loot_matrix', l.matrix) + '">loot matrix ' + esc(l.matrix) + '</a>)',
lootOdds(l.odds, 'roll'));
});
if (d.vendor) left += vendorCard(d.vendor);
if (d.skills.length) left += skillsCard(d.skills);
if (d.inventory) left += card('Starts with (inventory component)', d.inventory.map(function (i) {
return '<div>' + item(i.item) + ' &times; ' + num(i.count) + (i.equip ? ' <span class="badge bg-body-tertiary text-body border">equipped</span>' : '') + '</div>';
}).join(''));
if (d.missions) left += card('Missions here', d.missions.map(function (m) {
return '<div>' + refLink(m.mission) + (m.offers ? ' <span class="badge text-bg-primary">offers</span>' : '') + (m.accepts ? ' <span class="badge text-bg-success">takes turn-ins</span>' : '') + '</div>';
}).join(''));
if (d.collectibleMission) left += card('Collectible', 'Needs ' + refLink(d.collectibleMission));
right += d.model
? card('Model', '<div id="modelViewer" class="cdc-model rounded overflow-hidden bg-body-tertiary d-flex align-items-center justify-content-center">' +
'<button class="btn btn-sm btn-outline-primary" id="showModel">Show in 3D</button></div><div class="small text-body-secondary mt-1">' + esc(d.model) + '</div>')
: '';
right += sourcesCard(d.sources);
if (d.rewardedBy.length) right += card('Mission rewards', d.rewardedBy.map(function (r) {
return '<div>' + refLink(r.mission) + ' &times; ' + num(r.count) + (r.repeat ? ' <span class="badge bg-body-tertiary text-body border">on repeats</span>' : '') +
(r.choice ? ' <span class="badge bg-body-tertiary text-body border">one of a choice</span>' : '') + '</div>';
}).join(''));
right += countedBy(d.countedBy);
right += card('Components', d.components.length ? d.components.map(function (c) {
var title = '<span class="fw-semibold">' + esc(c.typeName) + '</span> <span class="text-body-secondary">type ' + c.type +
', id ' + c.componentId + (c.table ? ' in <a href="' + tableHash(c.table, { filters: [{ column: 'id', op: '=', value: String(c.componentId) }] }) + '">' + esc(c.table) + '</a>' : ', no table') + '</span>';
// Nothing to unfold without rows (components with no table of their own)
if (!c.rows.length) return '<div class="mb-2 ps-3">' + title + '</div>';
return '<details class="mb-2"><summary>' + title + '</summary>' +
(c.rows.length === 1 ? keyValues(c.rows[0], c.columns, c.table) : rowsTable(c.rows, c.table, c.columns)) + '</details>';
}).join('') : none());
right += card('Objects row', raw('All columns', keyValues(d.row, d.columns, 'Objects'))) + localized(d.localized);
html += '<div class="row g-3"><div class="col-xl-6">' + left + '</div><div class="col-xl-6">' + right + '</div></div>';
html += usedBy(d.usedBy, 'object', lot);
view.innerHTML = html;
var button = document.getElementById('showModel');
if (button) button.addEventListener('click', function () { showModel(lot, d.name); });
});
}
function itemCard(i) {
var rows = [
['Type', esc(i.itemTypeName) + ' <span class="text-body-secondary small">(' + esc(i.itemType) + ')</span>'],
['Equips to', i.equipLocation ? '<span class="font-monospace">' + esc(i.equipLocation) + '</span>' : none('Not equipped')],
['Stack size', num(i.stackSize)],
['Base value', i.baseValue >= 0 ? num(i.baseValue) + ' coins <span class="small text-body-secondary">(a vendor charges it &times; its buy scalar)</span>' : none('No coin price')]
];
if (i.price.altCurrency) rows.push(['Also costs', item(i.price.altCurrency.item) + ' &times; ' + num(i.price.altCurrency.base) + ' <span class="small text-body-secondary">(&times; buy scalar)</span>']);
if (i.price.costs.length) rows.push(['Crafting costs', items(i.price.costs, true)]);
if (i.commendation) rows.push(['Commendation cost', item(i.commendation.item) + ' &times; ' + num(i.commendation.count)]);
if (i.subItems.length) rows.push(['Equips with it', items(i.subItems)]);
if (i.preconditions.length) rows.push(['Preconditions', i.preconditions.map(function (p) {
return '<div><a href="' + tableHash('Preconditions', { filters: [{ column: 'id', op: '=', value: String(p.id) }] }) + '">' + esc(p.id) + '</a> ' + esc(p.reason) + '</div>';
}).join('')]);
var stats = i.stats || {};
var bonuses = ['life', 'armor', 'imagination'].filter(function (k) { return stats[k]; }).map(function (k) { return '+' + esc(stats[k]) + ' ' + k; });
if (bonuses.length) rows.push(['Stats', bonuses.join(', ')]);
(stats.skills || []).forEach(function (s) { rows.push(['Skill', iconById(s.icon) + '<a href="' + hashFor('skill', s.id) + '">' + esc(s.description) + '</a>']); });
if (i.set) rows.push(['Item set', iconById(i.set.icon) + esc(i.set.name || 'Set ' + i.set.id) + ' <span class="small text-body-secondary">rank ' + esc(i.set.rank) + '</span>' +
i.set.bonuses.map(function (b) {
var parts = ['life', 'armor', 'imagination'].filter(function (k) { return b.stats[k]; }).map(function (k) { return '+' + esc(b.stats[k]) + ' ' + k; })
.concat((b.stats.skills || []).map(function (s) { return esc(s.description); }));
return '<div class="small">' + esc(b.pieces) + ' pieces: ' + parts.join(', ') + '</div>';
}).join('')]);
return card('Item', '<table class="table table-sm mb-0"><tbody>' + rows.map(function (r) {
return '<tr><th class="fw-normal text-body-secondary" style="width: 10rem">' + r[0] + '</th><td>' + r[1] + '</td></tr>';
}).join('') + '</tbody></table>');
}
function destructibleCard(x) {
return card('Health and smashing (destructible component)', '<div class="row text-center mb-2">' +
stat('Life', num(x.life)) + stat('Armor', num(x.armor)) + stat('Imagination', num(x.imagination)) + stat('Level', num(x.level)) + '</div>' +
'<div>Coins when smashed: ' + (x.coins ? num(x.coins.min) + '&ndash;' + num(x.coins.max) : none()) +
' <span class="small text-body-secondary">(currency table at its level)</span></div>' +
'<div class="small text-body-secondary">Faction ' + esc(x.faction) + (x.isSmashable ? ' &middot; smashable' : '') + '</div>');
}
function vendorCard(v) {
var scalar = v.buyScalar ? 'prices are the base value &times; ' + num(v.buyScalar) : 'buy scalar 0: prices use the world\'s vendor_buy_multiplier';
return card('Sells (vendor component, <a href="' + hashFor('loot_matrix', v.matrix) + '">loot matrix ' + esc(v.matrix) + '</a>)',
'<div class="small text-body-secondary mb-2">' + scalar + '; buys back at &times; ' + num(v.sellScalar) +
(v.refreshTimeSeconds > 0 ? '; restocks every ' + num(v.refreshTimeSeconds / 60) + ' min' : '') + '</div>' +
v.stock.map(function (e) {
return '<div class="mb-2"><div class="small"><a href="' + hashFor('loot_table', e.lootTable) + '">Loot table ' + esc(e.lootTable) + '</a>: ' +
(e.all ? 'all of it' : (e.minToDrop === e.maxToDrop ? esc(e.minToDrop) : esc(e.minToDrop) + '&ndash;' + esc(e.maxToDrop)) + ' of its ' + e.items.length + ' items at random') + '</div>' +
table([{ text: 'Item' }, { text: 'Price', num: true }, { text: 'In stock', num: true }], e.items.map(function (i) {
var price = i.price.coins !== null ? num(i.price.coins) + ' coins' : '';
if (i.price.altCurrency) price += (price ? ' + ' : '') + num(i.price.altCurrency.count !== null ? i.price.altCurrency.count : i.price.altCurrency.base) + ' ' + esc(i.price.altCurrency.item.name);
i.price.costs.forEach(function (c) { price += (price ? ' + ' : '') + num(c.count) + ' ' + esc(c.item.name); });
return '<tr><td>' + item(i.item) + '</td><td class="num">' + (price || none('-')) + '</td><td class="num">' + (i.sold ? pct(i.chance) : '<span class="text-body-secondary" title="No item component: vendors skip it">never</span>') + '</td></tr>';
}), 'cdc-odds') + '</div>';
}).join(''));
}
function skillsCard(skills) {
return card('Skills', table([{ text: 'Skill' }, { text: 'Cost', num: true }, { text: 'Cooldown', num: true }, { text: 'castOnType <span class="fw-normal text-body-secondary">(raw)</span>', num: true }],
skills.map(function (s) {
return '<tr><td>' + iconById(s.skillIcon) + refLink(s.skill) + (s.description ? '<div class="small text-body-secondary">' + esc(s.description) + '</div>' : '') +
(s.behaviorID ? '<div class="small"><a href="' + hashFor('behavior', s.behaviorID) + '">behavior ' + esc(s.behaviorID) + '</a></div>' : '') + '</td>' +
'<td class="num">' + (s.imaginationcost ? num(s.imaginationcost) + ' imagination' : '') + '</td><td class="num">' + (s.cooldown ? num(s.cooldown) + ' s' : '') + '</td><td class="num">' + esc(s.castOnType) + '</td></tr>';
})));
}
// Where an object comes from: drops, vendors, packages, activity rewards; with the chances
function sourcesCard(sources) {
if (!sources.length) return '';
return card('Comes from', sources.map(function (s) {
var columns = columnsOf(s.table);
var rows = s.matrices.map(function (m) {
// Objects with the component, or the rows themselves (with their links) for other tables
var users = m.objects.slice(0, 5).map(function (o) { return '<div>' + item(o) + '</div>'; }).join('') +
(m.objects.length > 5 ? '<div class="small text-body-secondary">and ' + (m.objects.length - 5) + ' more</div>' : '') +
(m.rows || []).map(function (r) {
return '<div class="small">' + columns.filter(function (c) { return r[c.name] !== null && r[c.name] !== '' && r[c.name] !== undefined; }).map(function (c) {
return '<span class="text-body-secondary">' + esc(c.name) + '</span> ' + cell(r[c.name], c, r, s.table);
}).join(' &middot; ') + '</div>';
}).join('');
return '<tr><td><a href="' + hashFor('loot_matrix', m.matrix) + '">' + esc(m.matrix) + '</a></td><td class="num">' +
(m.chance !== undefined ? pct(m.chance) : '<span class="text-body-secondary" title="Only the first matrices\' odds are worked out">?</span>') +
'</td><td class="num">' + (m.expected !== undefined ? num(+m.expected.toPrecision(3)) : '') + '</td><td>' + (users || none()) + '</td></tr>';
});
var head = [{ text: 'Loot matrix' }, { text: s.vendor ? 'In stock' : 'Chance', num: true }, { text: s.vendor ? '' : 'Average', num: true }, { text: 'Used by' }];
return '<div class="mb-3"><div class="small text-body-secondary mb-1">Through ' + esc(s.table) + '.' + esc(s.column) + ' (' + rows.length + ')</div>' +
table(head, rows.slice(0, 10), 'cdc-odds') +
(rows.length > 10 ? later('All ' + rows.length, function () { return table(head, rows, 'cdc-odds'); }) : '') + '</div>';
}).join('') + '<div class="small text-body-secondary">Chance per smash, opened package or activity reward, before live event bonuses; for vendors, the chance it is in stock.</div>');
}
function showModel(lot, name) {
var container = document.getElementById('modelViewer');
container.textContent = 'Loading…';
import('/js/lddviewer.js').then(function (module) {
disposeViewer();
viewer = module.createViewer(container, {});
return viewer.load([{ id: lot, name: name, lot: lot, position: [0, 0, 0], rotation: [0, 0, 0, 1], url: '/api/cdclient/objects/' + lot + '/lxfml' }], 1);
}).catch(function (e) { container.textContent = 'The 3D view could not load: ' + e.message; });
}
// Prerequisites as the server reads them: each term joined to all the rest, so "a or b and c" is a or (b and c)
function prerequisites(terms, text) {
if (!terms.length || (terms.length === 1 && !terms[0].mission)) return none('None');
function term(t) {
if (!t.mission) return '<span class="badge text-bg-success">always met</span>';
return '<span class="cdc-chip badge bg-body-tertiary text-body border">' + refLink(t.mission) + (t.stateName ? ' in state ' + esc(t.stateName) : ' complete') + '</span>';
}
function from(i) {
var t = terms[i];
if (i === terms.length - 1) return term(t);
var rest = from(i + 1);
// Brackets where the rest joins differently than this term does
if (i + 1 < terms.length - 1 && terms[i + 1].or !== t.or) rest = '( ' + rest + ' )';
return term(t) + ' <b>' + (t.or ? 'or' : 'and') + '</b> ' + rest;
}
return '<div class="cdc-expr">' + from(0) + '</div><div class="small text-body-secondary mt-1">As the server reads <span class="font-monospace">' + esc(text) + '</span>: ' +
'each mission is joined to everything after it, and brackets don\'t group.</div>';
}
function taskCard(tasks) {
if (!tasks.length) return card('Tasks', none());
return card('Tasks', tasks.map(function (t) {
var parts = [];
if (t.targets.length) parts.push('<div><span class="small text-body-secondary">Targets:</span> ' + t.targets.map(item).join(', ') + '</div>');
if (t.targetText) parts.push('<div><span class="small text-body-secondary">Target group:</span> <span class="font-monospace">' + esc(t.targetText) + '</span></div>');
if (t.parameters.length) parts.push('<div><span class="small text-body-secondary">Parameters:</span> ' + t.parameters.map(item).join(', ') + '</div>');
if (t.racingParameter) parts.push('<div><span class="small text-body-secondary">Racing task:</span> ' + esc(t.racingParameter) + '</div>');
return '<div class="mb-3">' + iconById(t.iconID) + '<span class="badge text-bg-secondary">' + esc(t.taskTypeName) + '</span> ' +
'<span class="fw-semibold">&times; ' + num(t.targetValue) + '</span> ' + esc(t.description || '') +
(t.progressed ? '' : ' <span class="badge text-bg-warning" title="MissionTask::Progress has no case for this type">the server never progresses this type</span>') +
parts.join('') + raw('MissionTasks row (uid ' + esc(t.uid) + ')', keyValues(t.row, columnsOf('MissionTasks'), 'MissionTasks')) + '</div>';
}).join(''));
}
function rewardsCard(r, row) {
var choice = row.isChoiceReward ? ' <span class="small text-body-secondary">(the player picks one)</span>' : '';
var first = '<h6 class="small text-body-secondary">First completion' + choice + '</h6>' + (r.items.length ? items(r.items, true) : '');
var extras = [];
if (row.reward_currency > 0) extras.push(num(row.reward_currency) + ' coins');
if (row.LegoScore > 0) extras.push(num(row.LegoScore) + ' U-score');
if (row.reward_reputation > 0) extras.push(num(row.reward_reputation) + ' reputation');
first += extras.length ? '<div>' + extras.join(', ') + '</div>' : '';
if (r.emotes.length) first += '<div><span class="small text-body-secondary">Emotes:</span> ' + r.emotes.map(refLink).join(', ') + '</div>';
if (r.stats.length) first += r.stats.map(function (s) { return '<div><span class="font-monospace small">' + esc(s.column) + '</span> +' + num(s.value) + '</div>'; }).join('');
if (!r.items.length && !extras.length && !r.emotes.length && !r.stats.length) first += none();
var html = first;
if (row.repeatable) {
var again = [];
if (row.reward_currency_repeatable > 0) again.push(num(row.reward_currency_repeatable) + ' coins');
if (row.LegoScore > 0) again.push(num(row.LegoScore) + ' U-score');
if (row.reward_reputation > 0) again.push(num(row.reward_reputation) + ' reputation');
html += '<h6 class="small text-body-secondary mt-3">Each repeat' + choice + '</h6>' + items(r.repeatItems, true) + (again.length ? '<div>' + again.join(', ') + '</div>' : '');
}
return card('Rewards', html + '<div class="small text-body-secondary mt-2">At the level cap U-score is paid as coins.</div>');
}
function missionView(id) {
api.get('/api/cdclient/missions/' + id).then(function (d) {
if (api404(d)) return showError(d.error);
var row = d.row;
var badges = [row.isMission ? 'Mission' : 'Achievement'];
if (row.repeatable) badges.push('Repeatable' + (row.cooldownTime > 0 ? ' every ' + num(row.cooldownTime) + ' min' : ''));
if (row.time_limit > 0) badges.push('time_limit ' + num(row.time_limit));
var html = header(iconById(row.missionIconID, 40) + esc(d.name) + ' <span class="text-body-secondary fs-5">' + id + '</span>',
esc([row.defined_type, row.defined_subtype].filter(Boolean).join(' / ')) + ' ' + badges.map(function (b) { return '<span class="badge bg-body-tertiary text-body border">' + b + '</span>'; }).join(' '));
html += '<div class="row g-3"><div class="col-xl-6">';
html += taskCard(d.tasks) + rewardsCard(d.rewards, row);
html += card('Prerequisites', prerequisites(d.prerequisites, row.prereqMissionID || ''));
html += card('Unlocks', chips(d.unlocks)) + countedBy(d.countedBy);
html += '</div><div class="col-xl-6">';
html += card('Offered by', chips(d.offeredBy)) + card('Turned in to', chips(d.acceptedBy));
html += localized(d.text) + localized(d.localized) + card('Missions row', raw('All columns', keyValues(row, d.columns, 'Missions'))) + '</div></div>';
html += usedBy(d.usedBy, 'mission', id);
view.innerHTML = html;
});
}
// A behavior tree, nested; each behavior with its template (from the server's enum) and parameters
function behaviorTree(tree) {
var shown = {};
function node(behaviorId, label) {
var n = tree.nodes[String(behaviorId)];
var title = (label ? '<span class="text-body-secondary">' + esc(label) + ':</span> ' : '') +
'<a href="' + hashFor('behavior', behaviorId) + '">' + esc(behaviorId) + '</a> ';
if (!n) return '<div class="ms-3">' + title + '<span class="text-danger">not in BehaviorTemplate' + (tree.truncated ? ' (or past the depth shown)' : '') + '</span></div>';
title += '<span class="fw-semibold">' + esc(n.templateName) + '</span> <span class="small text-body-secondary">template ' + esc(n.templateID) + '</span>' +
(n.effectID ? ' <span class="small text-body-secondary">effect ' + esc(n.effectID) + (n.effectHandle ? ' ' + esc(n.effectHandle) : '') + '</span>' : '');
if (shown[behaviorId]) return '<div class="ms-3">' + title + ' <span class="small text-body-secondary">(shown above)</span></div>';
shown[behaviorId] = true;
var values = n.parameters.filter(function (p) { return p.child === null; });
var children = n.parameters.filter(function (p) { return p.child !== null; });
return '<details open><summary>' + title + '</summary>' +
(values.length ? '<table class="params"><tbody>' + values.map(function (p) {
return '<tr><td class="text-body-secondary">' + esc(p.name) + '</td><td>' + num(p.value) + '</td></tr>';
}).join('') + '</tbody></table>' : '') +
children.map(function (p) { return node(p.child, p.name); }).join('') + '</details>';
}
return '<div class="cdc-tree">' + node(tree.root, '') + '</div>' + (tree.truncated ? '<div class="small text-warning">Cut short: too big to show whole.</div>' : '');
}
function skillView(id) {
api.get('/api/cdclient/skills/' + id).then(function (d) {
if (api404(d)) return showError(d.error);
var row = d.row;
var html = header(iconById(row.skillIcon, 40) + esc(d.name) + ' <span class="text-body-secondary fs-5">' + id + '</span>', esc(d.description || ''));
html += '<div class="row text-center mb-3">' + stat('Imagination cost', num(row.imaginationcost || 0)) + stat('Cooldown', num(row.cooldown || 0) + ' s') +
stat('Cooldown group', num(row.cooldowngroup || 0)) + '</div>';
html += '<div class="row g-3"><div class="col-xl-7">' + card('Behavior tree' + (row.behaviorID ? ' <a href="' + hashFor('behavior', row.behaviorID) + '" class="small fw-normal">' + esc(row.behaviorID) + '</a>' : ''),
d.tree ? behaviorTree(d.tree) : none('No behavior')) + '</div><div class="col-xl-5">';
html += card('On objects', d.objects.length ? table([{ text: 'Object' }, { text: 'castOnType <span class="fw-normal text-body-secondary">(raw)</span>', num: true }], d.objects.map(function (o) {
return '<tr><td>' + item(o.object) + '</td><td class="num">' + esc(o.castOnType) + '</td></tr>';
})) : none());
html += countedBy(d.countedBy) + localized(d.localized) + card('SkillBehavior row', raw('All columns', keyValues(row, d.columns, 'SkillBehavior'))) + '</div></div>';
view.innerHTML = html + usedBy(d.usedBy, 'skill', id);
});
}
function behaviorView(id) {
api.get('/api/cdclient/behaviors/' + id).then(function (d) {
if (api404(d)) return showError(d.error);
var count = Object.keys(d.tree.nodes).length;
view.innerHTML = header('Behavior ' + id, nf.format(count) + (count === 1 ? ' behavior' : ' behaviors') + ' in this tree') +
card('Tree', behaviorTree(d.tree)) + usedBy(d.usedBy, 'behavior', id);
});
}
function activityView(id) {
api.get('/api/cdclient/activities/' + id).then(function (d) {
if (api404(d)) return showError(d.error);
var row = d.row;
var html = header(esc(d.name) + ' <span class="text-body-secondary fs-5">' + id + '</span>', d.zone ? 'Played in ' + refLink(d.zone) : '');
html += '<p>Costs to play: ' + (d.cost ? item(d.cost.item) + ' &times; ' + num(d.cost.count) : none('nothing')) + '</p>';
var rewards = d.rewards.map(function (r, i) {
return '<div class="mb-3"><div><span class="fw-semibold">Rating ' + esc(r.rating) + '</span>' + (i === 0 ? ' <span class="badge bg-body-tertiary text-body border" title="Activity instances give the first reward row">first row</span>' : '') +
(r.description ? ' <span class="text-body-secondary">' + esc(r.description) + '</span>' : '') + '</div>' +
'<div class="small">Coins: ' + (r.coins ? num(r.coins.min) + '&ndash;' + num(r.coins.max) : none()) + (r.matrix > 0 ? ' &middot; <a href="' + hashFor('loot_matrix', r.matrix) + '">loot matrix ' + esc(r.matrix) + '</a>' : '') + '</div>' +
(r.odds ? raw('Drops', lootOdds(r.odds, 'reward')) : '') + '</div>';
}).join('');
html += '<div class="row g-3"><div class="col-xl-7">' + card('Rewards', (rewards || none()) + '<div class="small text-body-secondary">Scripts that rate a result give the row with the highest rating at or below it; ' +
'activity instances give the first row. Coins come from the currency table at level 1.</div>') + '</div><div class="col-xl-5">';
html += countedBy(d.countedBy) + localized(d.localized) + card('Activities row', raw('All columns', keyValues(row, d.columns, 'Activities'))) + '</div></div>';
view.innerHTML = html + usedBy(d.usedBy, 'activity', id);
});
}
function zoneView(id) {
api.get('/api/cdclient/zones/' + id).then(function (d) {
if (api404(d)) return showError(d.error);
var c = d.caps;
var override = function (value) { return value !== null ? ' <span class="badge text-bg-primary" title="Set on the Instances page">override</span>' : ''; };
var html = header(esc(d.name) + ' <span class="text-body-secondary fs-5">' + id + '</span>', esc(d.row.mapFolder || '') + (d.row.zoneName ? ' &middot; ' + esc(d.row.zoneName) : ''));
html += '<div class="row g-3"><div class="col-xl-6">';
html += card('Players per instance', '<div class="row text-center mb-2">' + stat('Soft cap' + override(c.softOverride), num(c.soft)) + stat('Hard cap' + override(c.hardOverride), num(c.hard)) + '</div>' +
'<div class="small text-body-secondary">The master sends players to a running instance while it has fewer than the soft cap (friends joining them up to the hard cap), ' +
'otherwise starts a new one. The ZoneTable says ' + num(c.clientSoft) + ' and ' + num(c.clientHard) + '.' +
(c.overridesShown ? '' : ' Overrides set on the Instances page aren\'t shown without access to it.') + ' <a href="/instances">Instances</a></div>');
html += card('Zone control object', d.control ? item(d.control) : none('None set: the server spawns its default zone control object'));
html += '</div><div class="col-xl-6">' + localized(d.localized) + card('ZoneTable row', raw('All columns', keyValues(d.row, d.columns, 'ZoneTable'))) + '</div></div>';
view.innerHTML = html + usedBy(d.usedBy, 'zone', id);
});
}
function lootMatrixView(id) {
api.get('/api/cdclient/loot_matrix/' + id).then(function (d) {
if (api404(d)) return showError(d.error);
view.innerHTML = header('Loot matrix ' + id) + card('Drops', lootOdds(d.odds, 'roll')) + usedBy(d.usedBy, 'loot_matrix', id);
});
}
function lootTableView(id) {
api.get('/api/cdclient/loot_table/' + id).then(function (d) {
if (api404(d)) return showError(d.error);
view.innerHTML = header('Loot table ' + id, nf.format(d.items.length) + ' items, highest rarity first as the server sorts them') +
card('Items', table([{ text: 'Item' }, { text: 'Rarity', num: true }, { text: '' }], d.items.map(function (i) {
return '<tr><td>' + item(i.item) + '</td><td class="num">' + (rarity(i.rarity) || none('0')) + '</td><td>' +
(i.missionDrop ? '<span class="badge bg-body-tertiary text-body border" title="Dropped only while a mission needs it">mission item</span>' : '') + '</td></tr>';
}), 'cdc-odds') + '<div class="small text-body-secondary mt-2">The chance of each depends on the loot matrix and rarity table it is rolled with: open one of the matrices below.</div>') +
usedBy(d.usedBy, 'loot_table', id);
});
}
// ---- routing ----
function route() {
disposeViewer();
lazy = {};
var hash = window.location.hash.replace(/^#\/?/, '');
var queryAt = hash.indexOf('?');
var path = (queryAt === -1 ? hash : hash.slice(0, queryAt)).split('/').map(decodeURIComponent);
@@ -689,28 +170,15 @@
});
view.innerHTML = '<div class="text-body-secondary">Loading&hellip;</div>';
window.scrollTo(0, 0);
if (path[0] === '') return home();
if (path[0] === 'table') return tableView(path[1], params);
// #/object/1727 and the like: the rows it names, without adding a history entry
var id = parseInt(path[1], 10);
switch (path[0]) {
case '': return home();
case 'search': return search(path.slice(1).join('/'));
case 'table': return tableView(path[1], params);
case 'object': return objectView(id);
case 'mission': return missionView(id);
case 'skill': return skillView(id);
case 'behavior': return behaviorView(id);
case 'activity': return activityView(id);
case 'zone': return zoneView(id);
case 'loot_matrix': return lootMatrixView(id);
case 'loot_table': return lootTableView(id);
default: return showError('Nothing here.');
}
var target = !isNaN(id) && hashFor(path[0], id);
if (target) return window.location.replace(target);
showError('Nothing here.');
}
document.getElementById('searchForm').addEventListener('submit', function (e) {
e.preventDefault();
var text = document.getElementById('searchInput').value.trim();
if (text) window.location.hash = '#/search/' + encodeURIComponent(text);
});
window.addEventListener('hashchange', route);
api.get('/api/cdclient/schema').then(function (d) {

View File

@@ -4,57 +4,23 @@
{% block css %}
<style>
.cdc-kv th { width: 14rem; font-weight: normal; color: var(--bs-secondary-color); }
.cdc-kv td, .cdc-rows td { font-family: var(--bs-font-monospace); font-size: .82rem; }
.cdc-kv td { word-break: break-word; }
.cdc-rows { font-size: .82rem; }
.cdc-rows td { font-family: var(--bs-font-monospace); }
.cdc-rows th { white-space: nowrap; }
.cdc-rows-wrap { overflow-x: auto; }
.cdc-tables { columns: 14rem auto; column-gap: 1rem; }
.cdc-tables a { display: block; break-inside: avoid; }
.cdc-tree details { margin-left: 1rem; border-left: 2px solid var(--bs-border-color); padding-left: .5rem; }
.cdc-tree summary { cursor: pointer; }
.cdc-tree .params { font-size: .8rem; margin: .25rem 0 .25rem 1rem; }
.cdc-tree .params td { padding: 0 .75rem 0 0; font-family: var(--bs-font-monospace); }
.cdc-model { height: 420px; }
.cdc-chip { display: inline-block; margin: 0 .25rem .25rem 0; }
.cdc-null { color: var(--bs-tertiary-color); }
.cdc-icon { width: 20px; height: 20px; object-fit: contain; vertical-align: -4px; margin-right: .25rem; }
.cdc-odds td, .cdc-odds th { font-size: .85rem; }
.cdc-odds .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.cdc-stat { font-size: 1.25rem; font-weight: 600; }
.cdc-expr { line-height: 2; }
details > summary { cursor: pointer; }
</style>
{% endblock %}
{% block content %}
<div id="cdclientPage">
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<h2 class="mb-0 me-auto"><a href="#/" class="text-decoration-none text-body">CDClient Browser</a></h2>
<form class="d-flex gap-2" id="searchForm" role="search">
<input class="form-control form-control-sm" id="searchInput" placeholder="Name or ID" aria-label="Search the CDClient" style="min-width: 16rem">
<button class="btn btn-sm btn-primary">Search</button>
</form>
</div>
<h2 class="mb-3"><a href="#/" class="text-decoration-none text-body">CDClient Browser</a></h2>
<div id="view"><div class="text-body-secondary">Loading&hellip;</div></div>
</div>
{% endblock %}
{% block scripts %}
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
},
"integrity": {
"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js": "sha384-IDC7sAMAIMB/TZ6dgKKPPAKZ2bXXXP8+FBMBC8cU319eBhKITx+PaalhfDkDNH28",
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js": "sha384-aJoe4qqS/DgF2jh9njAuvA6QIveJYoCuYOfYjdFY8P3eawzmc9bEQ40jq2TvOyuP",
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/environments/RoomEnvironment.js": "sha384-9ItPPH2vgKbkvgrqTKq7ptGYe+WuAnqlZtlqleJW4/CJjnM5R7C+efD6iS7ADPJ9"
}
}
</script>
<script src="/api/bricks/materials.js"></script>
<script src="/js/cdclient.js"></script>
{% endblock %}

View File

@@ -1454,36 +1454,15 @@ world server later only apply to new captures.
### CDClient browser
**CDClient Browser** (`dev_cdclient`) searches and reads the game's CDClient database (`resServer/CDServer.sqlite`).
Besides the raw rows, each kind of thing has a view that shows it the way the server uses it:
**CDClient Browser** (`dev_cdclient`) is a raw viewer for the game's CDClient database (`resServer/CDServer.sqlite`),
for checking the values the server reads. It lists every table; each table is paged (50 rows), sortable by any column,
searchable across all columns and filterable by column (`=`, `!=`, `<`, `<=`, `>`, `>=`, contains, starts with, null,
not null). Values that point at other rows (a LOT, a loot matrix, a skill, a mission, a zone, an emote, ...) link to
the target table filtered to that ID; in `ComponentsRegistry`, `component_type` shows the type's name and
`component_id` links to the component's row. The view is in the URL, so it can be linked; `/cdclient#/object/<LOT>`
(or `mission`, `skill`, `loot_matrix`, ...) opens the target table filtered to that ID.
- **Search** by name (objects by the names players see, missions, skills, activities, zones) or by ID (anything with
that ID).
- **Tables**: every table in the database, paged, sortable, searchable and filterable by column. Values that point at
other rows (a LOT, a loot matrix, a skill, a mission, a zone, an emote, ...) are links.
- **Objects**: icon, rarity and description; for items their type, equip slot, stack size, value, other currencies and
crafting costs, stat bonuses, skills and item set; for destructibles their life, armor, imagination, level and coin
drop; what they drop or contain, with each item's chance; a vendor's stock with prices and how it restocks; skills;
starting inventory; the missions an NPC offers or takes. Then where the object comes from (smashables, packages,
activity rewards, vendors, each with its chance), the missions that reward it and the mission tasks that count it.
Objects built from bricks can be previewed in 3D (from the client's `BrickModels`, so `client_location` must be
set); objects drawn from `.nif` meshes can't.
- **Loot**: chances are worked out as `Loot.cpp` rolls them: each loot matrix entry rolls with its percent, then drops
its count of items, each rolling a rarity from the rarity table (the lowest `randmax` at or above the roll) and
picking evenly among the loot table's items of that rarity, or the next lower rarity it has. The page shows, per
item, the chance of getting it at least once and how many on average, before live event bonuses. A vendor sells all
of an entry's table when its min or max is 0, otherwise that many of its items at random, whatever the rarity.
- **Missions**: tasks with their targets and parameters resolved to what `MissionTask` compares them with (objects,
missions, skills, emotes, activities), rewards for the first completion and for repeats, prerequisites as the server
evaluates them (each mission joined to everything after it, so brackets don't group), who offers and takes it, what
it unlocks and the achievements that count it.
- **Skills** with their cost, cooldown, behavior tree and the objects that have them; **behavior trees** with each
behavior's template (named from the server's `BehaviorTemplate` enum), effect and parameters.
- **Activities**: where they are played, what they cost, and their rewards per rating with coins and drop chances.
- **Zones**: names, the player caps new instances get (the ZoneTable's, or an override set on the Instances page,
shown to those who can see that page) and what uses the zone.
Numbers the server has no names for (a skill's `castOnType`) or doesn't read (a loot matrix's `flagID`) are shown raw.
It shows values only. For worked-out views (loot odds, mission rewards, behavior trees) use lu-explorer.
It is read-only. Tables and columns come from the database itself; the page never sends SQL, only which table, column
and value to look at.

View File

@@ -1,6 +1,5 @@
#include <gtest/gtest.h>
#include "CDClientRules.h"
#include "CDClientSchema.h"
using namespace CDClientSchema;
@@ -61,9 +60,6 @@ TEST(CDClientSchemaTest, Links) {
EXPECT_FALSE(LinkFor(schema, T(schema, "ObjectBehaviors"), C(schema, "ObjectBehaviors", "BehaviorID")));
// No target table in the schema: no link
EXPECT_FALSE(LinkFor(schema, T(schema, "ObjectSkills"), C(schema, "ObjectSkills", "skillID")));
const auto toMatrices = ColumnsLinkingTo(schema, eLink::LOOT_MATRIX);
EXPECT_EQ(toMatrices.size(), 2u); // ActivityRewards and DestructibleComponent
}
TEST(CDClientSchemaTest, ComponentTables) {
@@ -170,105 +166,3 @@ TEST(CDClientSchemaTest, ChildBehaviorParameters) {
EXPECT_FALSE(IsChildBehaviorParameter("isEnemyFaction"));
EXPECT_FALSE(IsChildBehaviorParameter("radius"));
}
TEST(CDClientSchemaTest, PrerequisiteMissions) {
EXPECT_EQ(PrerequisiteMissions("815|812|813"), (std::vector<int32_t>{ 815, 812, 813 }));
EXPECT_EQ(PrerequisiteMissions("(509|229)&(812|813)"), (std::vector<int32_t>{ 509, 229, 812, 813 }));
EXPECT_EQ(PrerequisiteMissions("1296, (1684 | 1680)"), (std::vector<int32_t>{ 1296, 1684, 1680 }));
// ":2" is a mission state, not a mission
EXPECT_EQ(PrerequisiteMissions("236:2"), (std::vector<int32_t>{ 236 }));
EXPECT_TRUE(PrerequisiteMissions("").empty());
}
TEST(CDClientRulesTest, RarityChances) {
using namespace CDClientRules;
// RarityTable 2 as the server loads it (randmax descending): a roll takes the lowest row at or above it
const std::vector<RarityRow> rows{ { 1.0, 4 }, { 0.95, 3 }, { 0.75, 2 }, { 0.5, 1 } };
const auto chances = RarityChances(rows);
ASSERT_EQ(chances.size(), 4u);
EXPECT_EQ(chances[0].first, 4);
EXPECT_NEAR(chances[0].second, 0.05, 1e-9);
EXPECT_NEAR(chances[1].second, 0.2, 1e-9);
EXPECT_NEAR(chances[2].second, 0.25, 1e-9);
EXPECT_EQ(chances[3].first, 1);
EXPECT_NEAR(chances[3].second, 0.5, 1e-9);
// A roll above every row keeps rarity 1
const auto partial = RarityChances({ { 0.9, 2 }, { 0.4, 1 } });
ASSERT_EQ(partial.size(), 2u);
EXPECT_EQ(partial[0].first, 1);
EXPECT_NEAR(partial[0].second, 0.1 + 0.4, 1e-9);
EXPECT_NEAR(partial[1].second, 0.5, 1e-9);
}
TEST(CDClientRulesTest, Candidates) {
using namespace CDClientRules;
// Items of the rolled rarity
EXPECT_EQ(Candidates({ 1, 3, 4, 3 }, 3), (std::vector<size_t>{ 1, 3 }));
// None of it: the next lower rarity
EXPECT_EQ(Candidates({ 4, 3, 3, 1 }, 2), (std::vector<size_t>{ 3 }));
// A lower rarity found alone doesn't stop the search: the server keeps taking lower ones until two match
EXPECT_EQ(Candidates({ 3, 1, 1 }, 4), (std::vector<size_t>{ 0, 1, 2 }));
EXPECT_TRUE(Candidates({ 4 }, 2).empty());
}
TEST(CDClientRulesTest, ChancesAndOdds) {
using namespace CDClientRules;
const std::vector<RarityRow> rows{ { 1.0, 4 }, { 0.95, 3 }, { 0.75, 2 }, { 0.5, 1 } };
const auto perDrop = ChancePerDrop({ 1, 1, 2, 3, 4 }, rows);
EXPECT_NEAR(perDrop[0], 0.25, 1e-9);
EXPECT_NEAR(perDrop[2], 0.25, 1e-9);
EXPECT_NEAR(perDrop[3], 0.2, 1e-9);
EXPECT_NEAR(perDrop[4], 0.05, 1e-9);
double total = 0;
for (const auto chance : perDrop) total += chance;
EXPECT_NEAR(total, 1.0, 1e-9);
const auto once = Odds(0.5, 1, 1, 0.25);
EXPECT_NEAR(once.atLeastOne, 0.125, 1e-9);
EXPECT_NEAR(once.expected, 0.125, 1e-9);
const auto twice = Odds(1.0, 2, 2, 0.5);
EXPECT_NEAR(twice.atLeastOne, 0.75, 1e-9);
EXPECT_NEAR(twice.expected, 1.0, 1e-9);
// 1 to 3 drops: 1 - (0.5 + 0.25 + 0.125) / 3
EXPECT_NEAR(Odds(1.0, 1, 3, 0.5).atLeastOne, 1.0 - 0.875 / 3.0, 1e-9);
EXPECT_EQ(VendorStockChance(0, 3, 10), 1.0);
EXPECT_NEAR(VendorStockChance(2, 4, 10), 0.3, 1e-9);
EXPECT_EQ(VendorStockChance(5, 5, 2), 1.0);
}
TEST(CDClientRulesTest, Prerequisites) {
using namespace CDClientRules;
const auto terms = [](std::string_view text) {
std::string out;
for (const auto& term : ParsePrerequisites(text)) {
out += std::to_string(term.mission) + (term.state ? ":" + std::to_string(term.state) : "") + (term.orRest ? " or " : " and ");
}
return out;
};
// Right to left, brackets don't group: 509 or (229 and (812 or 813))
EXPECT_EQ(terms("(509|229)&(812|813)"), "509 or 229 and 812 or 813 and ");
EXPECT_EQ(terms("1|2,3"), "1 or 2 and 3 and ");
EXPECT_EQ(terms("1170:2"), "1170:2 and ");
EXPECT_EQ(terms("1882:10 | 1882:2"), "1882:10 or 1882:2 and ");
// Nothing: always met; a trailing "|" makes the whole thing always met, a trailing "," changes nothing
EXPECT_EQ(terms(""), "0 and ");
EXPECT_EQ(terms("5|"), "5 or 0 and ");
EXPECT_EQ(terms("5,"), "5 and ");
}
TEST(CDClientRulesTest, TaskMeanings) {
using namespace CDClientRules;
EXPECT_EQ(MeaningOf(eMissionTaskType::SMASH).target, (std::vector<eLink>{ eLink::OBJECT }));
EXPECT_TRUE(MeaningOf(eMissionTaskType::SMASH).targetGroupIds);
EXPECT_EQ(MeaningOf(eMissionTaskType::META).target, (std::vector<eLink>{ eLink::MISSION }));
EXPECT_FALSE(MeaningOf(eMissionTaskType::TALK_TO_NPC).targetGroupIds);
EXPECT_EQ(MeaningOf(eMissionTaskType::USE_SKILL).parameters, (std::vector<eLink>{ eLink::SKILL }));
EXPECT_TRUE(MeaningOf(eMissionTaskType::RACING).racingParameter);
EXPECT_FALSE(MeaningOf(eMissionTaskType::BUY).progressed);
// As MissionTask reads targetGroup: leading spaces are fine, anything else that isn't a number is skipped
EXPECT_EQ(NumberList("1,2, 3,-1,x, 4 "), (std::vector<int64_t>{ 1, 2, 3 }));
EXPECT_TRUE(NumberList("").empty());
}