make quest categories configurable
This commit is contained in:
@@ -133,14 +133,16 @@ To use newserv in other ways (e.g. for translating data), see the end of this do
|
||||
|
||||
newserv automatically finds quests in the system/quests/ directory. To install your own quests, or to use quests you've saved using the proxy's "save files" option, just put them in that directory and name them appropriately.
|
||||
|
||||
Standard quest files should be named like `q###-CATEGORY-VERSION.EXT`, battle quests should be named like `b###-VERSION.EXT`, challenge quests should be named like `c###-VERSION.EXT`, and Episode 3 download quests should be named like `e###-gc3.EXT`. The fields in each filename are:
|
||||
Standard quest files should be named like `q###-CATEGORY-VERSION.EXT`, battle quests should be named like `b###-VERSION.EXT`, challenge quests should be named like `c###-VERSION.EXT` for Episode 1 or `d###-VERSION.EXT` for Episode 2, and Episode 3 download quests should be named like `e###-gc3.EXT`. The fields in each filename are:
|
||||
- `###`: quest number (this doesn't really matter; it should just be unique across the PSO version)
|
||||
- `CATEGORY`: ret = Retrieval, ext = Extermination, evt = Events, shp = Shops, vr = VR, twr = Tower, gov = Government (BB only), dl = Download (these don't appear during online play), 1p = Solo (BB only)
|
||||
- `CATEGORY`: ret = Retrieval, ext = Extermination, evt = Events, shp = Shops, vr = VR, twr = Tower, gv1/gv2/gv4 = Government (BB only), dl = Download (these don't appear during online play), 1p = Solo (BB only)
|
||||
- `VERSION`: d1 = Dreamcast v1, dc = Dreamcast v2, pc = PC, gc = GameCube Episodes 1 & 2, gc3 = Episode 3, bb = Blue Burst
|
||||
- `EXT`: file extension (see table below)
|
||||
|
||||
For example, the GameCube version of Lost HEAT SWORD is in two files named `q058-ret-gc.bin` and `q058-ret-gc.dat`. newserv knows these files are quests because they're in the system/quests/ directory, it knows they're for PSO GC because the filenames contain `-gc`, and it puts them in the Retrieval category because the filenames contain `-ret`.
|
||||
|
||||
The type identifiers (`b`, `c`, `d`, `e`, or `q`) and categories are configurable. See QuestCategories in config.example.json for more information on how to make new categories or edit the existing categories.
|
||||
|
||||
There are multiple PSO quest formats out there; newserv supports all of them. It can also decode any known format to standard .bin/.dat format. Specifically:
|
||||
|
||||
| Format | Extension | Supported | Decode action |
|
||||
|
||||
+90
-112
@@ -20,6 +20,47 @@
|
||||
|
||||
using namespace std;
|
||||
|
||||
QuestCategoryIndex::Category::Category(uint32_t category_id, std::shared_ptr<const JSONObject> json)
|
||||
: category_id(category_id) {
|
||||
const auto& l = json->as_list();
|
||||
this->flags = l.at(0)->as_int();
|
||||
this->type = l.at(1)->as_string().at(0);
|
||||
this->short_token = l.at(2)->as_string();
|
||||
this->name = decode_sjis(l.at(3)->as_string());
|
||||
this->description = decode_sjis(l.at(4)->as_string());
|
||||
}
|
||||
|
||||
bool QuestCategoryIndex::Category::matches_flags(uint8_t request) const {
|
||||
// If the request is for v1 or v2 (hence it has the HIDE_ON_PRE_V3 flag set)
|
||||
// and the category also has that flag set, it never matches
|
||||
if (request & this->flags & Flag::HIDE_ON_PRE_V3) {
|
||||
return false;
|
||||
}
|
||||
return request & this->flags;
|
||||
}
|
||||
|
||||
QuestCategoryIndex::QuestCategoryIndex(std::shared_ptr<const JSONObject> json) {
|
||||
uint32_t next_category_id = 1;
|
||||
for (const auto& it : json->as_list()) {
|
||||
this->categories.emplace_back(next_category_id++, it);
|
||||
}
|
||||
}
|
||||
|
||||
const QuestCategoryIndex::Category& QuestCategoryIndex::find(char type, const std::string& short_token) const {
|
||||
// Technically we should index these and do a map lookup, but there will
|
||||
// probably always only be a small constant number of them
|
||||
for (const auto& it : this->categories) {
|
||||
if (it.type == type && it.short_token == short_token) {
|
||||
return it;
|
||||
}
|
||||
}
|
||||
throw out_of_range(string_printf("no category with type %c and short_token %s", type, short_token.c_str()));
|
||||
}
|
||||
|
||||
const QuestCategoryIndex::Category& QuestCategoryIndex::at(uint32_t category_id) const {
|
||||
return this->categories.at(category_id - 1);
|
||||
}
|
||||
|
||||
// GCI decoding logic
|
||||
|
||||
template <bool IsBigEndian>
|
||||
@@ -189,47 +230,6 @@ struct PSODownloadQuestHeader {
|
||||
le_uint32_t encryption_seed;
|
||||
} __attribute__((packed));
|
||||
|
||||
bool category_is_mode(QuestCategory category) {
|
||||
return (category == QuestCategory::BATTLE) ||
|
||||
(category == QuestCategory::CHALLENGE) ||
|
||||
(category == QuestCategory::EPISODE_3);
|
||||
}
|
||||
|
||||
const char* name_for_category(QuestCategory category) {
|
||||
switch (category) {
|
||||
case QuestCategory::RETRIEVAL:
|
||||
return "Retrieval";
|
||||
case QuestCategory::EXTERMINATION:
|
||||
return "Extermination";
|
||||
case QuestCategory::EVENT:
|
||||
return "Event";
|
||||
case QuestCategory::SHOP:
|
||||
return "Shop";
|
||||
case QuestCategory::VR:
|
||||
return "VR";
|
||||
case QuestCategory::TOWER:
|
||||
return "Tower";
|
||||
case QuestCategory::GOVERNMENT_EPISODE_1:
|
||||
return "GovernmentEp1";
|
||||
case QuestCategory::GOVERNMENT_EPISODE_2:
|
||||
return "GovernmentEp2";
|
||||
case QuestCategory::GOVERNMENT_EPISODE_4:
|
||||
return "GovernmentEp4";
|
||||
case QuestCategory::DOWNLOAD:
|
||||
return "Download";
|
||||
case QuestCategory::BATTLE:
|
||||
return "Battle";
|
||||
case QuestCategory::CHALLENGE:
|
||||
return "Challenge";
|
||||
case QuestCategory::SOLO:
|
||||
return "Solo";
|
||||
case QuestCategory::EPISODE_3:
|
||||
return "Episode3";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
struct PSOQuestHeaderDC { // Same format for DC v1 and v2, thankfully
|
||||
uint32_t start_offset;
|
||||
uint32_t unknown_offset1;
|
||||
@@ -288,10 +288,10 @@ struct PSOQuestHeaderBB {
|
||||
ptext<char16_t, 0x120> long_description;
|
||||
} __attribute__((packed));
|
||||
|
||||
Quest::Quest(const string& bin_filename)
|
||||
Quest::Quest(const string& bin_filename, shared_ptr<const QuestCategoryIndex> category_index)
|
||||
: internal_id(-1),
|
||||
menu_item_id(0),
|
||||
category(QuestCategory::UNKNOWN),
|
||||
category_id(0),
|
||||
episode(Episode::NONE),
|
||||
is_dcv1(false),
|
||||
joinable(false),
|
||||
@@ -345,49 +345,23 @@ Quest::Quest(const string& bin_filename)
|
||||
throw invalid_argument("empty filename");
|
||||
}
|
||||
|
||||
if (basename[0] == 'b') {
|
||||
this->category = QuestCategory::BATTLE;
|
||||
} else if (basename[0] == 'c') {
|
||||
this->category = QuestCategory::CHALLENGE;
|
||||
} else if (basename[0] == 'e') {
|
||||
this->category = QuestCategory::EPISODE_3;
|
||||
} else if (basename[0] != 'q') {
|
||||
throw invalid_argument("filename does not indicate mode");
|
||||
}
|
||||
|
||||
if (this->category != QuestCategory::EPISODE_3 && this->has_mnm_extension) {
|
||||
throw invalid_argument("non-Ep3 quest has .mnm extension");
|
||||
}
|
||||
|
||||
// If the quest category is still unknown, expect 3 tokens (one of them will
|
||||
// tell us the category)
|
||||
vector<string> tokens = split(basename, '-');
|
||||
if (tokens.size() != (2 + (this->category == QuestCategory::UNKNOWN))) {
|
||||
|
||||
string category_token;
|
||||
if (tokens.size() == 3) {
|
||||
category_token = std::move(tokens[1]);
|
||||
tokens.erase(tokens.begin() + 1);
|
||||
} else if (tokens.size() != 2) {
|
||||
throw invalid_argument("incorrect filename format");
|
||||
}
|
||||
|
||||
auto& category = category_index->find(basename[0], category_token);
|
||||
this->category_id = category.category_id;
|
||||
|
||||
// Parse the number out of the first token
|
||||
this->internal_id = strtoull(tokens[0].c_str() + 1, nullptr, 10);
|
||||
|
||||
// Get the category from the second token if needed
|
||||
if (this->category == QuestCategory::UNKNOWN) {
|
||||
static const unordered_map<string, QuestCategory> name_to_category({
|
||||
{"ret", QuestCategory::RETRIEVAL},
|
||||
{"ext", QuestCategory::EXTERMINATION},
|
||||
{"evt", QuestCategory::EVENT},
|
||||
{"shp", QuestCategory::SHOP},
|
||||
{"vr", QuestCategory::VR},
|
||||
{"twr", QuestCategory::TOWER},
|
||||
// Note: This will be overwritten later for Episode 2 & 4 quests - we
|
||||
// haven't parsed the episode number from the quest script yet
|
||||
{"gov", QuestCategory::GOVERNMENT_EPISODE_1},
|
||||
{"dl", QuestCategory::DOWNLOAD},
|
||||
{"1p", QuestCategory::SOLO},
|
||||
});
|
||||
this->category = name_to_category.at(tokens[1]);
|
||||
tokens.erase(tokens.begin() + 1);
|
||||
}
|
||||
|
||||
// Get the version from the second (or previously third) token
|
||||
static const unordered_map<string, GameVersion> name_to_version({
|
||||
{"d1", GameVersion::DC},
|
||||
{"dc", GameVersion::DC},
|
||||
@@ -439,7 +413,7 @@ Quest::Quest(const string& bin_filename)
|
||||
|
||||
case GameVersion::XB:
|
||||
case GameVersion::GC: {
|
||||
if (this->category == QuestCategory::EPISODE_3) {
|
||||
if (category.flags & QuestCategoryIndex::Category::Flag::EP3_DOWNLOAD) {
|
||||
if (bin_decompressed.size() != sizeof(Episode3::MapDefinition)) {
|
||||
throw invalid_argument("file is incorrect size");
|
||||
}
|
||||
@@ -485,21 +459,16 @@ Quest::Quest(const string& bin_filename)
|
||||
this->name = header->name;
|
||||
this->short_description = header->short_description;
|
||||
this->long_description = header->long_description;
|
||||
if (this->category == QuestCategory::GOVERNMENT_EPISODE_1) {
|
||||
if (this->episode == Episode::EP2) {
|
||||
this->category = QuestCategory::GOVERNMENT_EPISODE_2;
|
||||
} else if (this->episode == Episode::EP4) {
|
||||
this->category = QuestCategory::GOVERNMENT_EPISODE_4;
|
||||
} else if (this->episode != Episode::EP1) {
|
||||
throw invalid_argument("government quest has invalid episode number");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw logic_error("invalid quest game version");
|
||||
}
|
||||
|
||||
if (this->has_mnm_extension && this->episode != Episode::EP3) {
|
||||
throw runtime_error("non-Episode 3 quest has .mnm extension");
|
||||
}
|
||||
}
|
||||
|
||||
static string basename_for_filename(const string& filename) {
|
||||
@@ -511,7 +480,7 @@ static string basename_for_filename(const string& filename) {
|
||||
}
|
||||
|
||||
string Quest::bin_filename() const {
|
||||
if (this->category == QuestCategory::EPISODE_3) {
|
||||
if (this->episode == Episode::EP3) {
|
||||
return string_printf("m%06" PRId64 "p_e.bin", this->internal_id);
|
||||
} else {
|
||||
return basename_for_filename(this->file_basename + ".bin");
|
||||
@@ -519,7 +488,7 @@ string Quest::bin_filename() const {
|
||||
}
|
||||
|
||||
string Quest::dat_filename() const {
|
||||
if (this->category == QuestCategory::EPISODE_3) {
|
||||
if (this->episode == Episode::EP3) {
|
||||
throw logic_error("Episode 3 quests do not have .dat files");
|
||||
} else {
|
||||
return basename_for_filename(this->file_basename + ".dat");
|
||||
@@ -563,7 +532,7 @@ shared_ptr<const string> Quest::bin_contents() const {
|
||||
}
|
||||
|
||||
shared_ptr<const string> Quest::dat_contents() const {
|
||||
if (this->category == QuestCategory::EPISODE_3) {
|
||||
if (this->episode == Episode::EP3) {
|
||||
throw logic_error("Episode 3 quests do not have .dat files");
|
||||
}
|
||||
if (!this->dat_contents_ptr) {
|
||||
@@ -916,12 +885,15 @@ void add_write_file_commands(
|
||||
}
|
||||
|
||||
string Quest::export_qst(GameVersion version) const {
|
||||
if (this->category == QuestCategory::EPISODE_3) {
|
||||
throw runtime_error("Episode 3 quests cannot be encoded in QST format");
|
||||
bool is_ep3 = this->episode == Episode::EP3;
|
||||
if (is_ep3 && !this->is_dlq_encoded) {
|
||||
throw runtime_error("Episode 3 quests can only be encoded in download QST format");
|
||||
}
|
||||
|
||||
StringWriter w;
|
||||
|
||||
// Some tools expect both open file commands at the beginning, hence this
|
||||
// unfortunate abstraction-breaking.
|
||||
switch (version) {
|
||||
case GameVersion::DC:
|
||||
add_open_file_command<PSOCommandHeaderDCV3, S_OpenFile_DC_44_A6>(w, *this, true);
|
||||
@@ -942,11 +914,15 @@ string Quest::export_qst(GameVersion version) const {
|
||||
case GameVersion::GC:
|
||||
case GameVersion::XB:
|
||||
add_open_file_command<PSOCommandHeaderDCV3, S_OpenFile_PC_V3_44_A6>(w, *this, true);
|
||||
add_open_file_command<PSOCommandHeaderDCV3, S_OpenFile_PC_V3_44_A6>(w, *this, false);
|
||||
if (!is_ep3) {
|
||||
add_open_file_command<PSOCommandHeaderDCV3, S_OpenFile_PC_V3_44_A6>(w, *this, false);
|
||||
}
|
||||
add_write_file_commands<PSOCommandHeaderDCV3>(
|
||||
w, this->file_basename + ".bin", *this->bin_contents(), this->is_dlq_encoded);
|
||||
add_write_file_commands<PSOCommandHeaderDCV3>(
|
||||
w, this->file_basename + ".dat", *this->dat_contents(), this->is_dlq_encoded);
|
||||
if (!is_ep3) {
|
||||
add_write_file_commands<PSOCommandHeaderDCV3>(
|
||||
w, this->file_basename + ".dat", *this->dat_contents(), this->is_dlq_encoded);
|
||||
}
|
||||
break;
|
||||
case GameVersion::BB:
|
||||
add_open_file_command<PSOCommandHeaderBB, S_OpenFile_BB_44_A6>(w, *this, true);
|
||||
@@ -963,12 +939,14 @@ string Quest::export_qst(GameVersion version) const {
|
||||
return std::move(w.str());
|
||||
}
|
||||
|
||||
QuestIndex::QuestIndex(const string& directory) : directory(directory) {
|
||||
auto filename_set = list_directory(this->directory);
|
||||
vector<string> filenames(filename_set.begin(), filename_set.end());
|
||||
sort(filenames.begin(), filenames.end());
|
||||
QuestIndex::QuestIndex(
|
||||
const string& directory,
|
||||
std::shared_ptr<const QuestCategoryIndex> category_index)
|
||||
: directory(directory),
|
||||
category_index(category_index) {
|
||||
|
||||
uint32_t next_menu_item_id = 1;
|
||||
for (const auto& filename : filenames) {
|
||||
for (const auto& filename : list_directory_sorted(this->directory)) {
|
||||
string full_path = this->directory + "/" + filename;
|
||||
|
||||
if (ends_with(filename, ".gba")) {
|
||||
@@ -988,26 +966,26 @@ QuestIndex::QuestIndex(const string& directory) : directory(directory) {
|
||||
ends_with(filename, ".mnm.dlq") ||
|
||||
ends_with(filename, ".qst")) {
|
||||
try {
|
||||
shared_ptr<Quest> q(new Quest(full_path));
|
||||
shared_ptr<Quest> q(new Quest(full_path, this->category_index));
|
||||
q->menu_item_id = next_menu_item_id++;
|
||||
string ascii_name = encode_sjis(q->name);
|
||||
if (!this->version_menu_item_id_to_quest.emplace(
|
||||
make_pair(q->version, q->menu_item_id), q)
|
||||
.second) {
|
||||
if (!this->version_menu_item_id_to_quest.emplace(make_pair(q->version, q->menu_item_id), q).second) {
|
||||
throw logic_error("duplicate quest menu item id");
|
||||
}
|
||||
static_game_data_log.info("Indexed quest %s (%s => %s-%" PRId64 " (%" PRIu32 "), %s, %s, joinable=%s, dcv1=%s)",
|
||||
auto category_name = encode_sjis(this->category_index->at(q->category_id).name);
|
||||
static_game_data_log.info("Indexed quest %s (%s => %s-%" PRId64 " (%" PRIu32 "), %s, %s (%" PRIu32 "), joinable=%s, dcv1=%s)",
|
||||
ascii_name.c_str(),
|
||||
filename.c_str(),
|
||||
name_for_version(q->version),
|
||||
q->internal_id,
|
||||
q->menu_item_id,
|
||||
name_for_category(q->category),
|
||||
name_for_episode(q->episode),
|
||||
category_name.c_str(),
|
||||
q->category_id,
|
||||
q->joinable ? "true" : "false",
|
||||
q->is_dcv1 ? "true" : "false");
|
||||
} catch (const exception& e) {
|
||||
static_game_data_log.warning("Failed to parse quest file %s (%s)", filename.c_str(), e.what());
|
||||
static_game_data_log.warning("Failed to index quest file %s (%s)", filename.c_str(), e.what());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1023,14 +1001,14 @@ shared_ptr<const string> QuestIndex::get_gba(const string& name) const {
|
||||
}
|
||||
|
||||
vector<shared_ptr<const Quest>> QuestIndex::filter(
|
||||
GameVersion version, bool is_dcv1, QuestCategory category) const {
|
||||
GameVersion version, bool is_dcv1, uint32_t category_id) const {
|
||||
auto it = this->version_menu_item_id_to_quest.lower_bound(make_pair(version, 0));
|
||||
auto end_it = this->version_menu_item_id_to_quest.upper_bound(make_pair(version, 0xFFFFFFFF));
|
||||
|
||||
vector<shared_ptr<const Quest>> ret;
|
||||
for (; it != end_it; it++) {
|
||||
shared_ptr<const Quest> q = it->second;
|
||||
if ((q->is_dcv1 != is_dcv1) || (q->category != category)) {
|
||||
if ((q->is_dcv1 != is_dcv1) || (q->category_id != category_id)) {
|
||||
continue;
|
||||
}
|
||||
ret.emplace_back(q);
|
||||
@@ -1076,7 +1054,7 @@ shared_ptr<Quest> Quest::create_download_quest() const {
|
||||
|
||||
// This function should not be used for Episode 3 quests (they should be sent
|
||||
// to the client as-is, without any encryption or other preprocessing)
|
||||
if (this->category == QuestCategory::EPISODE_3) {
|
||||
if (this->episode == Episode::EP3) {
|
||||
throw logic_error("Episode 3 quests cannot be converted to download quests");
|
||||
}
|
||||
|
||||
|
||||
+36
-23
@@ -10,26 +10,38 @@
|
||||
#include "StaticGameData.hh"
|
||||
#include "Version.hh"
|
||||
|
||||
enum class QuestCategory {
|
||||
UNKNOWN = -1,
|
||||
RETRIEVAL = 0,
|
||||
EXTERMINATION,
|
||||
EVENT,
|
||||
SHOP,
|
||||
VR,
|
||||
TOWER,
|
||||
GOVERNMENT_EPISODE_1,
|
||||
GOVERNMENT_EPISODE_2,
|
||||
GOVERNMENT_EPISODE_4,
|
||||
DOWNLOAD,
|
||||
BATTLE,
|
||||
CHALLENGE,
|
||||
SOLO,
|
||||
EPISODE_3,
|
||||
};
|
||||
struct QuestCategoryIndex {
|
||||
struct Category {
|
||||
enum Flag {
|
||||
NORMAL = 0x01,
|
||||
BATTLE = 0x02,
|
||||
CHALLENGE = 0x04,
|
||||
SOLO = 0x08,
|
||||
GOVERNMENT = 0x10,
|
||||
DOWNLOAD = 0x20,
|
||||
EP3_DOWNLOAD = 0x40,
|
||||
HIDE_ON_PRE_V3 = 0x80,
|
||||
};
|
||||
|
||||
bool category_is_mode(QuestCategory category);
|
||||
const char* name_for_category(QuestCategory category);
|
||||
uint32_t category_id;
|
||||
uint8_t flags;
|
||||
char type;
|
||||
std::string short_token;
|
||||
std::u16string name;
|
||||
std::u16string description;
|
||||
|
||||
explicit Category(uint32_t category_id, std::shared_ptr<const JSONObject> json);
|
||||
|
||||
bool matches_flags(uint8_t request) const;
|
||||
};
|
||||
|
||||
std::vector<Category> categories;
|
||||
|
||||
explicit QuestCategoryIndex(std::shared_ptr<const JSONObject> json);
|
||||
|
||||
const Category& find(char type, const std::string& short_token) const;
|
||||
const Category& at(uint32_t category_id) const;
|
||||
};
|
||||
|
||||
class Quest {
|
||||
public:
|
||||
@@ -43,7 +55,7 @@ public:
|
||||
};
|
||||
int64_t internal_id;
|
||||
uint32_t menu_item_id;
|
||||
QuestCategory category;
|
||||
uint32_t category_id;
|
||||
Episode episode;
|
||||
bool is_dcv1;
|
||||
bool joinable;
|
||||
@@ -56,7 +68,7 @@ public:
|
||||
std::u16string short_description;
|
||||
std::u16string long_description;
|
||||
|
||||
Quest(const std::string& file_basename);
|
||||
Quest(const std::string& file_basename, std::shared_ptr<const QuestCategoryIndex> category_index);
|
||||
Quest(const Quest&) = default;
|
||||
Quest(Quest&&) = default;
|
||||
Quest& operator=(const Quest&) = default;
|
||||
@@ -91,6 +103,7 @@ private:
|
||||
|
||||
struct QuestIndex {
|
||||
std::string directory;
|
||||
std::shared_ptr<const QuestCategoryIndex> category_index;
|
||||
|
||||
std::map<std::pair<GameVersion, uint64_t>, std::shared_ptr<Quest>> version_menu_item_id_to_quest;
|
||||
|
||||
@@ -98,10 +111,10 @@ struct QuestIndex {
|
||||
|
||||
std::map<std::string, std::shared_ptr<std::string>> gba_file_contents;
|
||||
|
||||
QuestIndex(const std::string& directory);
|
||||
QuestIndex(const std::string& directory, std::shared_ptr<const QuestCategoryIndex> category_index);
|
||||
|
||||
std::shared_ptr<const Quest> get(GameVersion version, uint32_t id) const;
|
||||
std::shared_ptr<const std::string> get_gba(const std::string& name) const;
|
||||
std::vector<std::shared_ptr<const Quest>> filter(GameVersion version,
|
||||
bool is_dcv1, QuestCategory category) const;
|
||||
bool is_dcv1, uint32_t category_id) const;
|
||||
};
|
||||
|
||||
+26
-51
@@ -30,43 +30,6 @@ const char* QUEST_BARRIER_DISCONNECT_HOOK_NAME = "quest_barrier";
|
||||
const char* CARD_AUCTION_DISCONNECT_HOOK_NAME = "card_auction";
|
||||
const char* ADD_NEXT_CLIENT_DISCONNECT_HOOK_NAME = "add_next_game_client";
|
||||
|
||||
vector<MenuItem> quest_categories_menu({
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::RETRIEVAL), u"Retrieval", u"$E$C6Quests that involve\nretrieving an object", 0),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::EXTERMINATION), u"Extermination", u"$E$C6Quests that involve\ndestroying all\nmonsters", 0),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::EVENT), u"Events", u"$E$C6Quests that are part\nof an event", 0),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::SHOP), u"Shops", u"$E$C6Quests that contain\nshops", 0),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::VR), u"Virtual Reality", u"$E$C6Quests that are\ndone in a simulator", MenuItem::Flag::INVISIBLE_ON_DC | MenuItem::Flag::INVISIBLE_ON_PC),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::TOWER), u"Control Tower", u"$E$C6Quests that take\nplace at the Control\nTower", MenuItem::Flag::INVISIBLE_ON_DC | MenuItem::Flag::INVISIBLE_ON_PC),
|
||||
});
|
||||
|
||||
vector<MenuItem> quest_battle_menu({
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::BATTLE), u"Battle", u"$E$C6Battle mode rule\nsets", 0),
|
||||
});
|
||||
|
||||
vector<MenuItem> quest_challenge_menu({
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::CHALLENGE), u"Challenge", u"$E$C6Challenge mode\nquests", 0),
|
||||
});
|
||||
|
||||
vector<MenuItem> quest_solo_menu({
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::SOLO), u"Solo Quests", u"$E$C6Quests that require\na single player", 0),
|
||||
});
|
||||
|
||||
vector<MenuItem> quest_government_menu({
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::GOVERNMENT_EPISODE_1), u"Hero in Red", u"$E$CG-Red Ring Rico-\n$C6Quests that follow\nthe Episode 1\nstoryline", 0),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::GOVERNMENT_EPISODE_2), u"The Military's Hero", u"$E$CG-Heathcliff Flowen-\n$C6Quests that follow\nthe Episode 2\nstoryline", 0),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::GOVERNMENT_EPISODE_4), u"The Meteor Impact Incident", u"$E$C6Quests that follow\nthe Episode 4\nstoryline", 0),
|
||||
});
|
||||
|
||||
vector<MenuItem> quest_download_menu({
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::RETRIEVAL), u"Retrieval", u"$E$C6Quests that involve\nretrieving an object", 0),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::EXTERMINATION), u"Extermination", u"$E$C6Quests that involve\ndestroying all\nmonsters", 0),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::EVENT), u"Events", u"$E$C6Quests that are part\nof an event", 0),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::SHOP), u"Shops", u"$E$C6Quests that contain\nshops", 0),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::VR), u"Virtual Reality", u"$E$C6Quests that are\ndone in a simulator", MenuItem::Flag::INVISIBLE_ON_DC | MenuItem::Flag::INVISIBLE_ON_PC),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::TOWER), u"Control Tower", u"$E$C6Quests that take\nplace at the Control\nTower", MenuItem::Flag::INVISIBLE_ON_DC | MenuItem::Flag::INVISIBLE_ON_PC),
|
||||
MenuItem(static_cast<uint32_t>(QuestCategory::DOWNLOAD), u"Download", u"$E$C6Quests to download\nto your Memory Card", 0),
|
||||
});
|
||||
|
||||
static shared_ptr<const Menu> proxy_options_menu_for_client(
|
||||
shared_ptr<ServerState> s, shared_ptr<const Client> c) {
|
||||
shared_ptr<Menu> ret(new Menu(MenuID::PROXY_OPTIONS, u"Proxy options"));
|
||||
@@ -1714,16 +1677,25 @@ static void on_10(shared_ptr<ServerState> s, shared_ptr<Client> c,
|
||||
|
||||
case MainMenuItemID::DOWNLOAD_QUESTS:
|
||||
if (c->flags & Client::Flag::IS_EPISODE_3) {
|
||||
shared_ptr<Lobby> l = c->lobby_id ? s->find_lobby(c->lobby_id) : nullptr;
|
||||
auto quests = s->quest_index->filter(
|
||||
c->version(), c->flags & Client::Flag::IS_DC_V1, QuestCategory::EPISODE_3);
|
||||
// Episode 3 has only download quests, not online quests, so this is
|
||||
// always the download quest menu. (Episode 3 does actually have
|
||||
// online quests, but they're served via a server data request
|
||||
// instead of the file download paradigm that other versions use.)
|
||||
vector<shared_ptr<const Quest>> quests;
|
||||
for (const auto& category : s->quest_category_index->categories) {
|
||||
if (category.flags & QuestCategoryIndex::Category::Flag::EP3_DOWNLOAD) {
|
||||
quests = s->quest_index->filter(
|
||||
c->version(), c->flags & Client::Flag::IS_DC_V1, category.category_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
send_quest_menu(c, MenuID::QUEST, quests, true);
|
||||
} else {
|
||||
send_quest_menu(c, MenuID::QUEST_FILTER, quest_download_menu, true);
|
||||
uint8_t flags = QuestCategoryIndex::Category::Flag::DOWNLOAD;
|
||||
if (c->version() == GameVersion::DC || c->version() == GameVersion::PC) {
|
||||
flags |= QuestCategoryIndex::Category::Flag::HIDE_ON_PRE_V3;
|
||||
}
|
||||
send_quest_menu(c, MenuID::QUEST_FILTER, s->quest_category_index, flags);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1954,9 +1926,8 @@ static void on_10(shared_ptr<ServerState> s, shared_ptr<Client> c,
|
||||
break;
|
||||
}
|
||||
shared_ptr<Lobby> l = c->lobby_id ? s->find_lobby(c->lobby_id) : nullptr;
|
||||
auto quests = s->quest_index->filter(c->version(),
|
||||
c->flags & Client::Flag::IS_DC_V1,
|
||||
static_cast<QuestCategory>(item_id & 0xFF));
|
||||
auto quests = s->quest_index->filter(
|
||||
c->version(), c->flags & Client::Flag::IS_DC_V1, item_id);
|
||||
|
||||
// Hack: Assume the menu to be sent is the download quest menu if the
|
||||
// client is not in any lobby
|
||||
@@ -2330,29 +2301,33 @@ static void on_A2(shared_ptr<ServerState> s, shared_ptr<Client> c,
|
||||
send_lobby_message_box(c, u"$C6Episode 3 does not\nprovide online quests\nvia this interface.");
|
||||
|
||||
} else {
|
||||
vector<MenuItem>* menu = nullptr;
|
||||
|
||||
uint8_t flags = (c->version() == GameVersion::DC || c->version() == GameVersion::PC)
|
||||
? QuestCategoryIndex::Category::Flag::HIDE_ON_PRE_V3
|
||||
: 0;
|
||||
|
||||
if ((c->version() == GameVersion::BB) && flag) {
|
||||
menu = &quest_government_menu;
|
||||
flags |= QuestCategoryIndex::Category::Flag::GOVERNMENT;
|
||||
} else {
|
||||
switch (l->mode) {
|
||||
case GameMode::NORMAL:
|
||||
menu = &quest_categories_menu;
|
||||
flags |= QuestCategoryIndex::Category::Flag::NORMAL;
|
||||
break;
|
||||
case GameMode::BATTLE:
|
||||
menu = &quest_battle_menu;
|
||||
flags |= QuestCategoryIndex::Category::Flag::BATTLE;
|
||||
break;
|
||||
case GameMode::CHALLENGE:
|
||||
menu = &quest_challenge_menu;
|
||||
flags |= QuestCategoryIndex::Category::Flag::CHALLENGE;
|
||||
break;
|
||||
case GameMode::SOLO:
|
||||
menu = &quest_solo_menu;
|
||||
flags |= QuestCategoryIndex::Category::Flag::SOLO;
|
||||
break;
|
||||
default:
|
||||
throw logic_error("invalid game mode");
|
||||
}
|
||||
}
|
||||
|
||||
send_quest_menu(c, MenuID::QUEST_FILTER, *menu, false);
|
||||
send_quest_menu(c, MenuID::QUEST_FILTER, s->quest_category_index, flags);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-11
@@ -1254,15 +1254,19 @@ template <typename EntryT>
|
||||
void send_quest_menu_t(
|
||||
shared_ptr<Client> c,
|
||||
uint32_t menu_id,
|
||||
const vector<MenuItem>& items,
|
||||
bool is_download_menu) {
|
||||
shared_ptr<const QuestCategoryIndex> category_index,
|
||||
uint8_t flags) {
|
||||
bool is_download_menu = flags & (QuestCategoryIndex::Category::Flag::DOWNLOAD | QuestCategoryIndex::Category::Flag::EP3_DOWNLOAD);
|
||||
vector<EntryT> entries;
|
||||
for (const auto& item : items) {
|
||||
for (const auto& category : category_index->categories) {
|
||||
if (!category.matches_flags(flags)) {
|
||||
continue;
|
||||
}
|
||||
auto& e = entries.emplace_back();
|
||||
e.menu_id = menu_id;
|
||||
e.item_id = item.item_id;
|
||||
e.name = item.name;
|
||||
e.short_description = item.description;
|
||||
e.item_id = category.category_id;
|
||||
e.name = category.name;
|
||||
e.short_description = category.description;
|
||||
add_color_inplace(e.short_description);
|
||||
}
|
||||
send_command_vt(c, is_download_menu ? 0xA4 : 0xA2, entries.size(), entries);
|
||||
@@ -1290,20 +1294,20 @@ void send_quest_menu(shared_ptr<Client> c, uint32_t menu_id,
|
||||
}
|
||||
|
||||
void send_quest_menu(shared_ptr<Client> c, uint32_t menu_id,
|
||||
const vector<MenuItem>& items, bool is_download_menu) {
|
||||
shared_ptr<const QuestCategoryIndex> category_index, uint8_t flags) {
|
||||
switch (c->version()) {
|
||||
case GameVersion::PC:
|
||||
send_quest_menu_t<S_QuestMenuEntry_PC_A2_A4>(c, menu_id, items, is_download_menu);
|
||||
send_quest_menu_t<S_QuestMenuEntry_PC_A2_A4>(c, menu_id, category_index, flags);
|
||||
break;
|
||||
case GameVersion::DC:
|
||||
case GameVersion::GC:
|
||||
send_quest_menu_t<S_QuestMenuEntry_DC_GC_A2_A4>(c, menu_id, items, is_download_menu);
|
||||
send_quest_menu_t<S_QuestMenuEntry_DC_GC_A2_A4>(c, menu_id, category_index, flags);
|
||||
break;
|
||||
case GameVersion::XB:
|
||||
send_quest_menu_t<S_QuestMenuEntry_XB_A2_A4>(c, menu_id, items, is_download_menu);
|
||||
send_quest_menu_t<S_QuestMenuEntry_XB_A2_A4>(c, menu_id, category_index, flags);
|
||||
break;
|
||||
case GameVersion::BB:
|
||||
send_quest_menu_t<S_QuestMenuEntry_BB_A2_A4>(c, menu_id, items, is_download_menu);
|
||||
send_quest_menu_t<S_QuestMenuEntry_BB_A2_A4>(c, menu_id, category_index, flags);
|
||||
break;
|
||||
default:
|
||||
throw logic_error("unimplemented versioned command");
|
||||
|
||||
+1
-1
@@ -258,7 +258,7 @@ void send_game_menu(
|
||||
void send_quest_menu(std::shared_ptr<Client> c, uint32_t menu_id,
|
||||
const std::vector<std::shared_ptr<const Quest>>& quests, bool is_download_menu);
|
||||
void send_quest_menu(std::shared_ptr<Client> c, uint32_t menu_id,
|
||||
const std::vector<MenuItem>& items, bool is_download_menu);
|
||||
std::shared_ptr<const QuestCategoryIndex> category_index, uint8_t flags);
|
||||
void send_lobby_list(std::shared_ptr<Client> c, std::shared_ptr<ServerState> s);
|
||||
|
||||
void send_join_lobby(std::shared_ptr<Client> c, std::shared_ptr<Lobby> l);
|
||||
|
||||
+8
-1
@@ -751,6 +751,13 @@ void ServerState::parse_config(shared_ptr<const JSONObject> config_json) {
|
||||
this->ep3_menu_song = d.at("Episode3MenuSong")->as_int();
|
||||
} catch (const out_of_range&) {
|
||||
}
|
||||
|
||||
try {
|
||||
this->quest_category_index.reset(new QuestCategoryIndex(d.at("QuestCategories")));
|
||||
} catch (const exception& e) {
|
||||
throw runtime_error(string_printf(
|
||||
"QuestCategories is missing or invalid in config.json (%s) - see config.example.json for an example", e.what()));
|
||||
}
|
||||
}
|
||||
|
||||
void ServerState::load_licenses() {
|
||||
@@ -857,7 +864,7 @@ void ServerState::load_ep3_data() {
|
||||
|
||||
void ServerState::load_quest_index() {
|
||||
config_log.info("Collecting quest metadata");
|
||||
this->quest_index.reset(new QuestIndex("system/quests"));
|
||||
this->quest_index.reset(new QuestIndex("system/quests", this->quest_category_index));
|
||||
}
|
||||
|
||||
void ServerState::compile_functions() {
|
||||
|
||||
@@ -65,6 +65,7 @@ struct ServerState {
|
||||
std::shared_ptr<const PatchFileIndex> bb_patch_file_index;
|
||||
std::shared_ptr<const DOLFileIndex> dol_file_index;
|
||||
std::shared_ptr<const Episode3::DataIndex> ep3_data_index;
|
||||
std::shared_ptr<const QuestCategoryIndex> quest_category_index;
|
||||
std::shared_ptr<const QuestIndex> quest_index;
|
||||
std::shared_ptr<const LevelTable> level_table;
|
||||
std::shared_ptr<const BattleParamsIndex> battle_params;
|
||||
|
||||
@@ -303,6 +303,46 @@
|
||||
"Megid": [700, 6],
|
||||
},
|
||||
|
||||
// Quest category configuration. See README.md for information on how quest
|
||||
// files should be named. This list specifies the quest category names and
|
||||
// descriptions. (We don't use a map here because the category order
|
||||
// specified here is the order that appears in the quest menu.)
|
||||
"QuestCategories": [
|
||||
// Each entry is [type, token, flags, category_name, description].
|
||||
// These fields are:
|
||||
// flags: a bit field containing the following:
|
||||
// 0x01 - appears in normal mode
|
||||
// 0x02 - appears in battle mode
|
||||
// 0x04 - appears in challenge mode
|
||||
// 0x08 - appears in solo mode (BB)
|
||||
// 0x10 - appears at government counter (BB)
|
||||
// 0x20 - appears in download quest menu
|
||||
// 0x40 - appears in Episode 3 download quest menu
|
||||
// 0x80 - hidden on pre-V3 versions (DC, PC)
|
||||
// type: the character that newserv expects at the beginning of the quest
|
||||
// filename, generally one of b, c, e, or q.
|
||||
// short_token: the token newserv expects to see in quest filenames after
|
||||
// the quest number.
|
||||
// category_name: what appears in the quest menu on the client.
|
||||
// description: what appears in the category description window (may
|
||||
// contain color escape codes like $C6).
|
||||
[0x21, "q", "ret", "Retrieval", "$E$C6Quests that involve\nretrieving an object"],
|
||||
[0x21, "q", "ext", "Extermination", "$E$C6Quests that involve\ndestroying all\nmonsters"],
|
||||
[0x21, "q", "evt", "Events", "$E$C6Quests that are part\nof an event"],
|
||||
[0x21, "q", "shp", "Shops", "$E$C6Quests that contain\nshops"],
|
||||
[0xA1, "q", "vr", "Virtual Reality", "$E$C6Quests that are\ndone in a simulator"],
|
||||
[0xA1, "q", "twr", "Control Tower", "$E$C6Quests that take\nplace at the Control\nTower"],
|
||||
[0x02, "b", "", "Battle", "$E$C6Battle mode rule\nsets"],
|
||||
[0x04, "c", "", "Challenge (Episode 1)", "$E$C6Challenge mode\nquests in Episode 1"],
|
||||
[0x84, "d", "", "Challenge (Episode 2)", "$E$C6Challenge mode\nquests in Episode 2"],
|
||||
[0x08, "q", "1p", "Solo", "$E$C6Quests that require\na single player"],
|
||||
[0x10, "q", "gv1", "Hero in Red", "$E$CG-Red Ring Rico-\n$C6Quests that follow\nthe Episode 1\nstoryline"],
|
||||
[0x10, "q", "gv2", "The Military's Hero", "$E$CG-Heathcliff Flowen-\n$C6Quests that follow\nthe Episode 2\nstoryline"],
|
||||
[0x10, "q", "gv4", "The Meteor Impact Incident", "$E$C6Quests that follow\nthe Episode 4\nstoryline"],
|
||||
[0x20, "q", "dl", "Download", "$E$C6Quests to download\nto your Memory Card"],
|
||||
[0x40, "e", "", "Download", "$E$C6Quests to download\nto your Memory Card"],
|
||||
],
|
||||
|
||||
// Whether to enable patches on Episode 3 USA. This functionality depends on
|
||||
// exploiting a bug in Episode 3, and while it seems to work reliably on
|
||||
// Dolphin, it hasn't been tested on a real GameCube. So, newserv doesn't
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user