add method to override enemy EXP in quests

This commit is contained in:
Martin Michelsen
2025-11-07 22:52:31 -08:00
parent 1d11879142
commit b80ed0021b
6 changed files with 98 additions and 13 deletions
+4
View File
@@ -744,6 +744,10 @@ QuestIndex::QuestIndex(
vq->meta.lock_status_register = metadata_json.get_int("LockStatusRegister");
} catch (const out_of_range&) {
}
try {
vq->meta.enemy_exp_overrides = QuestMetadata::parse_enemy_exp_overrides(metadata_json.at("EnemyEXPOverrides"));
} catch (const out_of_range&) {
}
try {
vq->meta.common_item_set_name = metadata_json.at("CommonItemSetName").as_string();
} catch (const out_of_range&) {
+50 -1
View File
@@ -44,6 +44,9 @@ void QuestMetadata::assert_compatible(const QuestMetadata& other) const {
"quest version has a different lock status register (existing: {:04X}, new: {:04X})",
this->lock_status_register, other.lock_status_register));
}
if (this->enemy_exp_overrides != other.enemy_exp_overrides) {
throw runtime_error("quest version has different enemy EXP overrides");
}
if (!this->battle_rules != !other.battle_rules) {
throw runtime_error(std::format(
"quest version has a different battle rules presence state (existing: {}, new: {})",
@@ -140,11 +143,19 @@ phosg::JSON QuestMetadata::json() const {
for (const auto& fa : this->area_for_floor) {
floors_json.emplace_back(fa);
}
auto enemy_exp_overrides_json = phosg::JSON::dict();
for (const auto& [key, exp_override] : this->enemy_exp_overrides) {
auto difficulty = static_cast<Difficulty>((key >> 24) & 3);
auto floor = static_cast<uint8_t>((key >> 16) & 0xFF);
auto enemy_type = static_cast<EnemyType>(key & 0xFFFF);
auto key_str = std::format("{}:0x{:02X}:{}", name_for_difficulty(difficulty), floor, phosg::name_for_enum(enemy_type));
enemy_exp_overrides_json.emplace(key_str, exp_override);
}
return phosg::JSON::dict({
{"CategoryID", this->category_id},
{"Number", this->quest_number},
{"Episode", name_for_episode(this->episode)},
{"FloorAssignments", floors_json},
{"FloorAssignments", std::move(floors_json)},
{"Joinable", this->joinable},
{"MaxPlayers", this->max_players},
{"BattleRules", this->battle_rules ? this->battle_rules->json() : phosg::JSON(nullptr)},
@@ -160,5 +171,43 @@ phosg::JSON QuestMetadata::json() const {
{"DefaultDropMode", phosg::name_for_enum(this->default_drop_mode)},
{"AllowStartFromChatCommand", this->allow_start_from_chat_command},
{"LockStatusRegister", (this->lock_status_register >= 0) ? this->lock_status_register : phosg::JSON(nullptr)},
{"EnemyEXPOverrides", std::move(enemy_exp_overrides_json)},
});
}
std::unordered_map<uint32_t, uint32_t> QuestMetadata::parse_enemy_exp_overrides(const phosg::JSON& json) {
try {
std::unordered_map<uint32_t, uint32_t> ret;
for (const auto& [key, exp_value_json] : json.as_dict()) {
// Key is like "Difficulty:Floor:EnemyType" or "Difficulty:EnemyType"
auto key_tokens = phosg::split(key, ':');
static const unordered_map<string, Difficulty> difficulty_keys(
{{"Normal", Difficulty::NORMAL}, {"Hard", Difficulty::HARD}, {"VeryHard", Difficulty::VERY_HARD}, {"Ultimate", Difficulty::ULTIMATE}});
Difficulty difficulty = Difficulty::NORMAL;
EnemyType enemy_type = EnemyType::UNKNOWN;
uint8_t floor = 0xFF;
if (key_tokens.size() == 2) {
enemy_type = phosg::enum_for_name<EnemyType>(key_tokens[1]);
} else if (key_tokens.size() == 3) {
floor = stoul(key_tokens[1], nullptr, 0);
enemy_type = phosg::enum_for_name<EnemyType>(key_tokens[2]);
} else {
throw runtime_error("malformatted key: " + key);
}
difficulty = difficulty_keys.at(key_tokens[0]);
if (floor == 0xFF) {
for (size_t floor = 0; floor < 0x12; floor++) {
ret.emplace(QuestMetadata::exp_override_key(difficulty, floor, enemy_type), exp_value_json->as_int());
}
} else {
ret.emplace(QuestMetadata::exp_override_key(difficulty, floor, enemy_type), exp_value_json->as_int());
}
}
return ret;
} catch (const exception& e) {
throw std::runtime_error(std::format("invalid enemy EXP overrides: ", e.what()));
}
}
+8
View File
@@ -9,10 +9,12 @@
#include <vector>
#include "CommonItemSet.hh"
#include "EnemyType.hh"
#include "IntegralExpression.hh"
#include "Map.hh"
#include "PlayerSubordinates.hh"
#include "RareItemSet.hh"
#include "StaticGameData.hh"
struct QuestMetadata {
// This structure contains configuration that should be the same across all
@@ -40,11 +42,17 @@ struct QuestMetadata {
ServerDropMode default_drop_mode = ServerDropMode::CLIENT; // Ignored if allowed_drop_modes == 0
bool allow_start_from_chat_command = false;
int16_t lock_status_register = -1;
std::unordered_map<uint32_t, uint32_t> enemy_exp_overrides;
std::string name;
std::string short_description;
std::string long_description;
static std::unordered_map<uint32_t, uint32_t> parse_enemy_exp_overrides(const phosg::JSON& json);
static inline uint32_t exp_override_key(Difficulty difficulty, uint8_t floor, EnemyType enemy_type) {
return (static_cast<uint32_t>(difficulty) << 24) | (static_cast<uint32_t>(floor) << 16) | static_cast<uint32_t>(enemy_type);
}
void assign_default_areas(Version version, Episode episode);
void assert_compatible(const QuestMetadata& other) const;
phosg::JSON json() const;
+5 -5
View File
@@ -1904,15 +1904,15 @@ static const QuestScriptOpcodeDefinition opcode_defs[] = {
// Specifies which enemy should be affected by subsequent get_*_data
// opcodes (the following 4 definitions). valueA is the battle parameter
// index for the desired enemy.
{0xF891, "load_enemy_data", nullptr, {I32}, F_V2_V4 | F_ARGS},
{0xF891, "set_override_enemy_bp_index", "load_enemy_data", {I32}, F_V2_V4 | F_ARGS},
// Replaces enemy stats with the given structures (PlayerStats, AttackData,
// ResistData, or MovementData) for the enemy previously specified with
// load_enemy_data.
{0xF892, "get_physical_data", nullptr, {{LABEL16, Arg::DataType::PLAYER_STATS, "stats"}}, F_V2_V4},
{0xF893, "get_attack_data", nullptr, {{LABEL16, Arg::DataType::ATTACK_DATA, "attack_data"}}, F_V2_V4},
{0xF894, "get_resist_data", nullptr, {{LABEL16, Arg::DataType::RESIST_DATA, "resist_data"}}, F_V2_V4},
{0xF895, "get_movement_data", nullptr, {{LABEL16, Arg::DataType::MOVEMENT_DATA, "movement_data"}}, F_V2_V4},
{0xF892, "set_enemy_physical_data", "get_physical_data", {{LABEL16, Arg::DataType::PLAYER_STATS, "stats"}}, F_V2_V4},
{0xF893, "set_enemy_attack_data", "get_attack_data", {{LABEL16, Arg::DataType::ATTACK_DATA, "attack_data"}}, F_V2_V4},
{0xF894, "set_enemy_resist_data", "get_resist_data", {{LABEL16, Arg::DataType::RESIST_DATA, "resist_data"}}, F_V2_V4},
{0xF895, "set_enemy_movement_data", "get_movement_data", {{LABEL16, Arg::DataType::MOVEMENT_DATA, "movement_data"}}, F_V2_V4},
// Reads 2 bytes or 4 bytes from the event flags in the system file.
// regA = event flag index
+16 -7
View File
@@ -3919,11 +3919,19 @@ static void add_player_exp(shared_ptr<Client> c, uint32_t exp, uint16_t from_ene
static uint32_t base_exp_for_enemy_type(
shared_ptr<const BattleParamsIndex> bp_index,
shared_ptr<const Quest> quest, // Null in free play
EnemyType enemy_type,
Episode current_episode,
Difficulty difficulty,
uint8_t area,
uint8_t floor,
bool is_solo) {
if (quest) {
try {
return quest->meta.enemy_exp_overrides.at(QuestMetadata::exp_override_key(difficulty, floor, enemy_type));
} catch (const out_of_range&) {
}
}
// Always try the current episode first. If the current episode is Ep4, try
// Ep1 next if in Crater and Ep2 next if in Desert (this mirrors the logic in
// BB Patch Project's omnispawn patch).
@@ -3936,10 +3944,11 @@ static uint32_t base_exp_for_enemy_type(
episode_order[1] = Episode::EP1;
episode_order[2] = Episode::EP4;
} else if (current_episode == Episode::EP4) {
if (area <= 0x05) {
uint8_t area = quest->meta.area_for_floor.at(floor);
if (area <= 0x28) { // Crater
episode_order[1] = Episode::EP1;
episode_order[2] = Episode::EP2;
} else {
} else { // Desert
episode_order[1] = Episode::EP2;
episode_order[2] = Episode::EP1;
}
@@ -3956,11 +3965,11 @@ static uint32_t base_exp_for_enemy_type(
}
}
throw runtime_error(std::format(
"no base exp is available (type={}, episode={}, difficulty={}, area={:02X}, solo={})",
"no base exp is available (type={}, episode={}, difficulty={}, floor={:02X}, solo={})",
phosg::name_for_enum(enemy_type),
name_for_episode(current_episode),
name_for_difficulty(difficulty),
area,
floor,
is_solo ? "true" : "false"));
}
@@ -4006,7 +4015,7 @@ static asio::awaitable<void> on_steal_exp_bb(shared_ptr<Client> c, SubcommandMes
auto type = ene_st->type(c->version(), l->episode, l->difficulty, l->event);
uint32_t enemy_exp = base_exp_for_enemy_type(
s->battle_params, type, l->episode, l->difficulty, ene_st->super_ene->floor, l->mode == GameMode::SOLO);
s->battle_params, l->quest, type, l->episode, l->difficulty, ene_st->super_ene->floor, l->mode == GameMode::SOLO);
// Note: The original code checks if special.type is 9, 10, or 11, and skips
// applying the android bonus if so. We don't do anything for those special
@@ -4052,7 +4061,7 @@ static asio::awaitable<void> on_enemy_exp_request_bb(shared_ptr<Client> c, Subco
auto type = ene_st->type(c->version(), l->episode, l->difficulty, l->event);
double base_exp = base_exp_for_enemy_type(
s->battle_params, type, l->episode, l->difficulty, ene_st->super_ene->floor, l->mode == GameMode::SOLO);
s->battle_params, l->quest, type, l->episode, l->difficulty, ene_st->super_ene->floor, l->mode == GameMode::SOLO);
l->log.info_f("Base EXP for this enemy ({}) is {:g}", phosg::name_for_enum(type), base_exp);
for (size_t client_id = 0; client_id < 4; client_id++) {