allow creating items by name instead of by data

This commit is contained in:
Martin Michelsen
2023-06-18 22:45:10 -07:00
parent f333a88aaf
commit 10ab688207
8 changed files with 473 additions and 86 deletions
+2 -32
View File
@@ -1176,24 +1176,9 @@ static void server_command_item(
check_is_game(l, true);
check_cheats_enabled(s, l);
string data = parse_data_string(encode_sjis(args));
if (data.size() < 2) {
send_text_message(c, u"$C6Item codes must be\n2 bytes or more");
return;
}
if (data.size() > 16) {
send_text_message(c, u"$C6Item codes must be\n16 bytes or fewer");
return;
}
PlayerInventoryItem item;
item.data = item_for_string(encode_sjis(args));
item.data.id = l->generate_item_id(c->lobby_client_id);
if (data.size() <= 12) {
memcpy(item.data.data1.data(), data.data(), data.size());
} else {
memcpy(item.data.data1.data(), data.data(), 12);
memcpy(item.data.data2.data(), data.data() + 12, data.size() - 12);
}
l->add_item(item, c->area, c->x, c->z);
send_drop_stacked_item(l, item.data, c->area, c->x, c->z);
@@ -1223,24 +1208,9 @@ static void proxy_command_item(
bool set_drop = (!args.empty() && (args[0] == u'!'));
string data = parse_data_string(encode_sjis(args));
if (data.size() < 2) {
send_text_message(session.client_channel, u"$C6Item codes must be\n2 bytes or more");
return;
}
if (data.size() > 16) {
send_text_message(session.client_channel, u"$C6Item codes must be\n16 bytes or fewer");
return;
}
PlayerInventoryItem item;
item.data = item_for_string(encode_sjis(set_drop ? args.substr(1) : args));
item.data.id = random_object<uint32_t>();
if (data.size() <= 12) {
memcpy(item.data.data1.data(), data.data(), data.size());
} else {
memcpy(item.data.data1.data(), data.data(), 12);
memcpy(item.data.data2.data(), data.data() + 12, data.size() - 12);
}
if (set_drop) {
session.next_drop_item = item;
+305 -32
View File
@@ -4,6 +4,8 @@
using namespace std;
static string S_RANK_NAME_CHARACTERS("\0ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_", 0x20);
ItemData::ItemData() {
this->clear();
}
@@ -50,17 +52,54 @@ uint32_t ItemData::primary_identifier() const {
// The game treats any item starting with 04 as Meseta, and ignores the rest
// of data1 (the value is in data2)
if (this->data1[0] == 0x04) {
return 0x00040000;
return 0x040000;
}
if (this->data1[0] == 0x03 && this->data1[1] == 0x02) {
return 0x00030200; // Tech disk (data1[2] is level, so omit it)
return 0x030200; // Tech disk (data1[2] is level, so omit it)
} else if (this->data1[0] == 0x02) {
return 0x00020000 | (this->data1[1] << 8); // Mag
return 0x020000 | (this->data1[1] << 8); // Mag
} else {
return (this->data1[0] << 16) | (this->data1[1] << 8) | this->data1[2];
}
}
bool ItemData::is_wrapped() const {
switch (this->data1[0]) {
case 0:
case 1:
return this->data1[4] & 0x40;
case 2:
return this->data2[2] & 0x40;
case 3:
return !this->is_stackable() && (this->data1[3] & 0x40);
case 4:
return false;
default:
throw runtime_error("invalid item data");
}
}
void ItemData::unwrap() {
switch (this->data1[0]) {
case 0:
case 1:
this->data1[4] &= 0xBF;
break;
case 2:
this->data2[2] &= 0xBF;
break;
case 3:
if (!this->is_stackable()) {
this->data1[3] &= 0xBF;
}
break;
case 4:
break;
default:
throw runtime_error("invalid item data");
}
}
bool ItemData::is_stackable() const {
return this->max_stack_size() > 1;
}
@@ -1364,6 +1403,236 @@ const unordered_map<uint32_t, ItemNameInfo> name_info_for_primary_identifier({
{0x031903, "Team Points 10000"},
});
ItemData::ItemData(const string& orig_description) {
this->data1d.clear(0);
this->id = 0xFFFFFFFF;
this->data2d = 0;
string desc = tolower(orig_description);
if (ends_with(desc, " meseta")) {
this->data1[0] = 0x04;
this->data2d = stol(desc, nullptr, 10);
return;
}
if (starts_with(desc, "disk:")) {
auto tokens = split(desc, ' ');
if (tokens.size() != 2) {
throw runtime_error("invalid tech disk name");
}
if (!starts_with(tokens[0], "disk:") || !starts_with(tokens[1], "lv.")) {
throw runtime_error("invalid tech disk format");
}
uint8_t tech = technique_for_name(tokens[0].substr(5));
uint8_t level = stoul(tokens[1].substr(3), nullptr, 10);
this->data1[0] = 0x03;
this->data1[1] = 0x02;
this->data1[2] = level;
this->data1[4] = tech;
return;
}
bool is_wrapped = starts_with(desc, "wrapped ");
if (is_wrapped) {
desc = desc.substr(8);
}
uint8_t weapon_special = 0;
for (const auto& it : name_for_weapon_special) {
if (!it.second) {
continue;
}
string prefix = tolower(it.second);
prefix += ' ';
if (starts_with(desc, prefix)) {
weapon_special = it.first;
desc = desc.substr(prefix.size());
break;
}
}
static map<string, uint32_t> primary_identifier_for_name;
if (primary_identifier_for_name.empty()) {
for (const auto& it : name_info_for_primary_identifier) {
primary_identifier_for_name.emplace(tolower(it.second.name), it.first);
}
}
auto name_it = primary_identifier_for_name.lower_bound(desc);
// Look up to 3 places before the lower bound. We have to do this to catch
// cases like Sange vs. Sange & Yasha - if the input is like "Sange 0/...",
// then we'll see Sange & Yasha first, which we should skip.
size_t lookback = 0;
while (lookback < 4) {
if (name_it != primary_identifier_for_name.end() &&
desc.starts_with(name_it->first)) {
break;
} else if (name_it == primary_identifier_for_name.begin()) {
throw runtime_error("no such item");
} else {
name_it--;
lookback++;
}
}
if (lookback >= 4) {
throw runtime_error("item not found");
}
desc = desc.substr(name_it->first.size());
if (starts_with(desc, " ")) {
desc = desc.substr(1);
}
uint32_t primary_identifier = name_it->second;
this->data1[0] = (primary_identifier >> 16) & 0xFF;
this->data1[1] = (primary_identifier >> 8) & 0xFF;
this->data1[2] = primary_identifier & 0xFF;
if (this->data1[0] == 0x00) {
// Weapons: add special, grind and percentages (or name, if S-rank)
this->data1[4] = weapon_special | (is_wrapped ? 0x40 : 0x00);
auto tokens = split(desc, ' ');
for (auto& token : tokens) {
if (token.empty()) {
continue;
}
if (starts_with(token, "+")) {
token = token.substr(1);
this->data1[3] = stoul(token, nullptr, 10);
} else if (this->is_s_rank_weapon()) {
if (token.size() > 8) {
throw runtime_error("s-rank name too long");
}
uint8_t char_indexes[8] = {0, 0, 0, 0, 0, 0, 0, 0};
for (size_t z = 0; z < token.size(); z++) {
char ch = toupper(token[z]);
size_t pos = S_RANK_NAME_CHARACTERS.find(ch);
if (pos == string::npos) {
throw runtime_error(string_printf("s-rank name contains invalid character %02hhX (%c)", ch, ch));
}
char_indexes[z] = pos;
}
this->data1w[3] = (char_indexes[1] & 0x1F) | ((char_indexes[0] & 0x1F) << 5);
this->data1w[4] = (char_indexes[4] & 0x1F) | ((char_indexes[3] & 0x1F) << 5) | ((char_indexes[2] & 0x1F) << 10);
this->data1w[5] = (char_indexes[7] & 0x1F) | ((char_indexes[6] & 0x1F) << 5) | ((char_indexes[5] & 0x1F) << 10);
} else {
auto p_tokens = split(token, '/');
if (p_tokens.size() > 5) {
throw runtime_error("invalid bonuses token");
}
uint8_t bonus_index = 0;
for (size_t z = 0; z < p_tokens.size(); z++) {
int8_t bonus_value = stol(p_tokens[z], nullptr, 10);
if (bonus_value == 0) {
continue;
}
if (bonus_index >= 3) {
throw runtime_error("weapon has too many bonuses");
}
this->data1[6 + (2 * bonus_index)] = z + 1;
this->data1[7 + (2 * bonus_index)] = static_cast<uint8_t>(bonus_value);
bonus_index++;
}
}
}
} else if (this->data1[0] == 0x01) {
if (this->data1[1] == 0x03) { // Unit
static const unordered_map<string, uint16_t> modifiers({
{"--", 0xFFFC},
{"-", 0xFFFE},
{"", 0x0000},
{"+", 0x0002},
{"++", 0x0004},
});
uint16_t modifier = modifiers.at(desc);
this->data1[7] = modifier & 0xFF;
this->data1[8] = (modifier >> 8) & 0xFF;
} else { // Armor/shield
for (const auto& token : split(desc, ' ')) {
if (token.empty()) {
continue;
} else if (!starts_with(token, "+")) {
throw runtime_error("invalid armor/shield modifier");
}
if (ends_with(token, "def")) {
this->data1w[3] = static_cast<uint16_t>(stol(token.substr(1, token.size() - 4), nullptr, 10));
} else if (ends_with(token, "evp")) {
this->data1w[4] = static_cast<uint16_t>(stol(token.substr(1, token.size() - 4), nullptr, 10));
} else {
this->data1[5] = stoul(token.substr(1), nullptr, 10);
}
}
}
if (is_wrapped) {
this->data1[4] |= 0x40;
}
} else if (this->data1[0] == 0x02) {
for (const auto& token : split(desc, ' ')) {
if (starts_with(token, "pb:")) { // Photon blasts
auto pb_tokens = split(token.substr(3), ',');
if (pb_tokens.size() > 3) {
throw runtime_error("too many photon blasts specified");
}
static const unordered_map<string, uint8_t> name_to_pb_num({
{"f", 0},
{"e", 1},
{"g", 2},
{"p", 3},
{"l", 4},
{"m&y", 5},
});
for (const auto& pb_token : pb_tokens) {
this->add_mag_photon_blast(name_to_pb_num.at(pb_token));
}
} else if (ends_with(token, "%")) { // Synchro
this->data2[0] = stoul(token.substr(0, token.size() - 1), nullptr, 10);
} else if (ends_with(token, "iq")) { // IQ
this->data2[1] = stoul(token.substr(0, token.size() - 2), nullptr, 10);
} else if (!token.empty() && isdigit(token[0])) { // Stats
auto s_tokens = split(token, '/');
if (s_tokens.size() != 4) {
throw runtime_error("incorrect stat count");
}
this->data1w[2] = stoul(s_tokens[0], nullptr, 10);
this->data1w[3] = stoul(s_tokens[1], nullptr, 10);
this->data1w[4] = stoul(s_tokens[2], nullptr, 10);
this->data1w[5] = stoul(s_tokens[3], nullptr, 10);
} else { // Color
this->data2[3] = mag_color_for_name.at(token);
}
}
if (is_wrapped) {
this->data2[2] |= 0x40;
}
} else if (this->data1[0] == 0x03) {
if (this->max_stack_size() > 1) {
if (starts_with(desc, "x")) {
this->data1[5] = stoul(desc.substr(1), nullptr, 10);
} else {
this->data1[5] = 1;
}
} else if (!desc.empty()) {
throw runtime_error("item cannot be stacked");
}
if (is_wrapped) {
this->data1[3] |= 0x40;
}
} else {
throw logic_error("invalid item class");
}
}
string ItemData::hex() const {
return string_printf("%02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX (%08" PRIX32 ") %02hhX%02hhX%02hhX%02hhX",
this->data1[0], this->data1[1], this->data1[2], this->data1[3],
@@ -1402,6 +1671,10 @@ string ItemData::name(bool include_color_codes) const {
if ((this->data1[0] == 0x02) && (this->data2[2] & 0x40)) {
ret_tokens.emplace_back("Wrapped");
}
// And tools can be wrapped if they aren't stackable
if ((this->data1[0] == 0x03) && !this->is_stackable() && (this->data1[3] & 0x40)) {
ret_tokens.emplace_back("Wrapped");
}
// Add the item name. Technique disks are special because the level is part of
// the primary identifier, so we manually generate the name instead of looking
@@ -1434,7 +1707,7 @@ string ItemData::name(bool include_color_codes) const {
ret_tokens.emplace_back(string_printf("+%hhu", this->data1[3]));
}
if (this->is_s_rank_weapon() && (this->data1[6] & 0x18)) {
if (this->is_s_rank_weapon()) {
// S-rank (has name instead of percent bonuses)
uint8_t char_indexes[8] = {
static_cast<uint8_t>((this->data1w[3] >> 5) & 0x1F),
@@ -1446,11 +1719,10 @@ string ItemData::name(bool include_color_codes) const {
static_cast<uint8_t>((this->data1w[5] >> 5) & 0x1F),
static_cast<uint8_t>(this->data1w[5] & 0x1F),
};
const char* translation_table = "\0ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_";
string name;
for (size_t x = 0; x < 8; x++) {
char ch = translation_table[char_indexes[x]];
char ch = S_RANK_NAME_CHARACTERS[char_indexes[x]];
if (ch == 0) {
break;
}
@@ -1552,33 +1824,12 @@ string ItemData::name(bool include_color_codes) const {
token += pb_names[x];
}
ret_tokens.emplace_back(std::move(token));
}
static const vector<const char*> mag_colors({
/* 00 */ "red",
/* 01 */ "blue",
/* 02 */ "yellow",
/* 03 */ "green",
/* 04 */ "purple",
/* 05 */ "black",
/* 06 */ "white",
/* 07 */ "cyan",
/* 08 */ "brown",
/* 09 */ "orange",
/* 0A */ "light blue",
/* 0B */ "olive",
/* 0C */ "light cyan",
/* 0D */ "dark purple",
/* 0E */ "grey",
/* 0F */ "light grey",
/* 10 */ "pink",
/* 11 */ "dark cyan",
/* 12 */ "costume color",
});
try {
ret_tokens.emplace_back(string_printf("(%s)", mag_colors.at(this->data2[3])));
} catch (const out_of_range&) {
ret_tokens.emplace_back(string_printf("(!CL:%02hhX)", this->data2[3]));
}
try {
ret_tokens.emplace_back(string_printf("(%s)", name_for_mag_color.at(this->data2[3])));
} catch (const out_of_range&) {
ret_tokens.emplace_back(string_printf("(!CL:%02hhX)", this->data2[3]));
}
// For tools, add the amount (if applicable)
@@ -1603,3 +1854,25 @@ string ItemData::name(bool include_color_codes) const {
return ret;
}
}
ItemData item_for_string(const string& desc) {
try {
return ItemData(desc);
} catch (const exception&) {
string data = parse_data_string(desc);
if (data.size() < 2) {
throw runtime_error("item code too short");
}
if (data.size() > 16) {
throw runtime_error("item code too long");
}
ItemData ret;
if (data.size() <= 12) {
memcpy(ret.data1.data(), data.data(), data.size());
} else {
memcpy(ret.data1.data(), data.data(), 12);
memcpy(ret.data2.data(), data.data() + 12, data.size() - 12);
}
return ret;
}
}
+38
View File
@@ -47,6 +47,38 @@ struct ItemMagStats {
};
struct ItemData { // 0x14 bytes
// QUICK ITEM FORMAT REFERENCE
// data1/0 data1/4 data1/8 data2
// Weapon: 00ZZZZGG SS00AABB AABBAABB 00000000
// Armor: 0101ZZ00 FFTTDDDD EEEE0000 00000000
// Shield: 0102ZZ00 FFTTDDDD EEEE0000 00000000
// Unit: 0103ZZ00 FF0000RR RR000000 00000000
// Mag: 02ZZLLWW HHHHIIII JJJJKKKK YYQQPPVV
// Tool: 03ZZZZFF 00CC0000 00000000 00000000
// Meseta: 04000000 00000000 00000000 MMMMMMMM
// A = attribute type (for S-ranks, custom name)
// B = attribute amount (for S-ranks, custom name)
// C = stack size (for tools)
// D = DEF bonus
// E = EVP bonus
// F = flags (40=present; for tools, unused if item is stackable)
// G = weapon grind
// H = mag DEF
// I = mag POW
// J = mag DEX
// K = mag MIND
// L = mag level
// M = meseta amount
// P = mag flags (40=present, 04=has left pb, 02=has right pb, 01=has center pb)
// Q = mag IQ
// R = unit modifier (little-endian)
// S = weapon flags (80=unidentified, 40=present) and special (low 6 bits)
// T = slot count
// V = mag color
// W = photon blasts
// Y = mag synchro
// Z = item ID
union {
parray<uint8_t, 12> data1;
parray<le_uint16_t, 6> data1w;
@@ -60,6 +92,7 @@ struct ItemData { // 0x14 bytes
} __attribute__((packed));
ItemData();
explicit ItemData(const std::string& orig_description);
ItemData(const ItemData& other);
ItemData& operator=(const ItemData& other);
@@ -72,6 +105,9 @@ struct ItemData { // 0x14 bytes
std::string name(bool include_color_codes) const;
uint32_t primary_identifier() const;
bool is_wrapped() const;
void unwrap();
bool is_stackable() const;
size_t stack_size() const;
size_t max_stack_size() const;
@@ -104,3 +140,5 @@ struct ItemData { // 0x14 bytes
static bool compare_for_sort(const ItemData& a, const ItemData& b);
} __attribute__((packed));
ItemData item_for_string(const std::string& desc);
+3 -8
View File
@@ -74,14 +74,9 @@ void player_use_item(shared_ptr<ServerState> s, shared_ptr<Client> c, size_t ite
}
armor.data.data1[5]++;
} else if ((item.data.data1[0] == 0x02) && (item.data.data2[2] & 0x40)) {
// Unwrap mag present
item.data.data2[2] &= 0xBF;
should_delete_item = false;
} else if ((item.data.data1[0] != 0x02) && (item.data.data1[4] & 0x40)) {
// Unwrap non-mag present
item.data.data1[4] &= 0xBF;
} else if (item.data.is_wrapped()) {
// Unwrap present
item.data.unwrap();
should_delete_item = false;
} else if (item_identifier == 0x003300) {
+16
View File
@@ -225,6 +225,7 @@ enum class Behavior {
FORMAT_ITEMRT_REL,
SHOW_EP3_DATA,
DESCRIBE_ITEM,
ENCODE_ITEM,
PARSE_OBJECT_GRAPH,
REPLAY_LOG,
CAT_CLIENT,
@@ -255,6 +256,7 @@ static bool behavior_takes_input_filename(Behavior b) {
(b == Behavior::EXTRACT_GSL) ||
(b == Behavior::EXTRACT_BML) ||
(b == Behavior::DESCRIBE_ITEM) ||
(b == Behavior::ENCODE_ITEM) ||
(b == Behavior::PARSE_OBJECT_GRAPH) ||
(b == Behavior::REPLAY_LOG) ||
(b == Behavior::CAT_CLIENT) ||
@@ -435,6 +437,8 @@ int main(int argc, char** argv) {
behavior = Behavior::SHOW_EP3_DATA;
} else if (!strcmp(argv[x], "describe-item")) {
behavior = Behavior::DESCRIBE_ITEM;
} else if (!strcmp(argv[x], "encode-item")) {
behavior = Behavior::ENCODE_ITEM;
} else if (!strcmp(argv[x], "parse-object-graph")) {
behavior = Behavior::PARSE_OBJECT_GRAPH;
} else if (!strcmp(argv[x], "replay-log")) {
@@ -1130,6 +1134,18 @@ int main(int argc, char** argv) {
break;
}
case Behavior::ENCODE_ITEM: {
ItemData item(input_filename);
string desc = item.name(false);
log_info("Data: %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX %02hhX%02hhX%02hhX%02hhX -------- %02hhX%02hhX%02hhX%02hhX",
item.data1[0], item.data1[1], item.data1[2], item.data1[3],
item.data1[4], item.data1[5], item.data1[6], item.data1[7],
item.data1[8], item.data1[9], item.data1[10], item.data1[11],
item.data2[0], item.data2[1], item.data2[2], item.data2[3]);
log_info("Description: %s", desc.c_str());
break;
}
case Behavior::SHOW_EP3_DATA: {
config_log.info("Collecting Episode 3 data");
Episode3::DataIndex index("system/ep3", Episode3::BehaviorFlag::LOAD_CARD_TEXT);
+1 -14
View File
@@ -761,22 +761,9 @@ Proxy session commands:\n\
throw runtime_error("proxy session is not game leader");
}
string data = parse_data_string(command_args, nullptr, ParseDataFlags::ALLOW_FILES);
if (data.size() < 2) {
throw runtime_error("data too short");
}
if (data.size() > 16) {
throw runtime_error("data too long");
}
PlayerInventoryItem item;
item.data = item_for_string(command_args);
item.data.id = random_object<uint32_t>();
if (data.size() <= 12) {
memcpy(item.data.data1.data(), data.data(), data.size());
} else {
memcpy(item.data.data1.data(), data.data(), 12);
memcpy(item.data.data2.data(), data.data() + 12, data.size() - 12);
}
if (command_name == "set-next-item") {
session->next_drop_item = item;
+103
View File
@@ -545,3 +545,106 @@ uint8_t technique_for_name(const string& name) {
uint8_t technique_for_name(const u16string& name) {
return technique_for_name(encode_sjis(name));
}
const vector<const char*> name_for_mag_color({
/* 00 */ "red",
/* 01 */ "blue",
/* 02 */ "yellow",
/* 03 */ "green",
/* 04 */ "purple",
/* 05 */ "black",
/* 06 */ "white",
/* 07 */ "cyan",
/* 08 */ "brown",
/* 09 */ "orange",
/* 0A */ "light-blue",
/* 0B */ "olive",
/* 0C */ "light-cyan",
/* 0D */ "dark-purple",
/* 0E */ "grey",
/* 0F */ "light-grey",
/* 10 */ "pink",
/* 11 */ "dark-cyan",
/* 12 */ "costume",
});
const unordered_map<string, uint8_t> mag_color_for_name({
{"red", 0x00},
{"blue", 0x01},
{"yellow", 0x02},
{"green", 0x03},
{"purple", 0x04},
{"black", 0x05},
{"white", 0x06},
{"cyan", 0x07},
{"brown", 0x08},
{"orange", 0x09},
{"light-blue", 0x0A},
{"olive", 0x0B},
{"light-cyan", 0x0C},
{"dark-purple", 0x0D},
{"grey", 0x0E},
{"light-grey", 0x0F},
{"pink", 0x10},
{"dark-cyan", 0x11},
{"costume-color", 0x12},
});
uint8_t drop_area_for_name(const std::string& name) {
static const unordered_map<string, uint8_t> areas({
{"forest1", 0},
{"forest2", 1},
{"dragon", 2},
{"caves1", 2},
{"cave1", 2},
{"caves2", 3},
{"cave2", 3},
{"caves3", 4},
{"cave3", 4},
{"derolle", 5},
{"mines1", 5},
{"mine1", 5},
{"mines2", 6},
{"mine2", 6},
{"volopt", 7},
{"ruins1", 7},
{"ruin1", 7},
{"ruins2", 8},
{"ruin2", 8},
{"ruins3", 9},
{"ruin3", 9},
{"darkfalz", 9},
{"vrtemplealpha", 0},
{"vrtemplebeta", 1},
{"barbaray", 2},
{"vrspaceshipalpha", 2},
{"vrspaceshipbeta", 3},
{"goldragon", 5},
{"centralcontrolarea", 4},
{"cca", 4},
{"jungleareanorth", 5},
{"junglenorth", 5},
{"jungleareaeast", 5},
{"jungleeast", 5},
{"mountain", 6},
{"seaside", 7},
{"galgryphon", 8},
{"seabedupper", 8},
{"seabedlower", 9},
{"olgaflow", 9},
{"seasidenight", 7},
{"tower", 9},
{"cratereast", 2},
{"craterwest", 3},
{"cratersouth", 4},
{"craternorth", 5},
{"craterinterior", 6},
{"subdesert1", 7},
{"subdesert2", 8},
{"subdesert3", 9},
{"saintmillion", 9},
});
return areas.at(tolower(name));
}
+5
View File
@@ -74,3 +74,8 @@ const char* name_for_difficulty(uint8_t difficulty);
char abbreviation_for_difficulty(uint8_t difficulty);
char char_for_language_code(uint8_t language);
extern const std::vector<const char*> name_for_mag_color;
extern const std::unordered_map<std::string, uint8_t> mag_color_for_name;
uint8_t drop_area_for_name(const std::string& name);