diff --git a/src/Quest.cc b/src/Quest.cc index 6ebf4c3a..538141d8 100644 --- a/src/Quest.cc +++ b/src/Quest.cc @@ -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&) { diff --git a/src/QuestMetadata.cc b/src/QuestMetadata.cc index ce6f46ec..3a84be17 100644 --- a/src/QuestMetadata.cc +++ b/src/QuestMetadata.cc @@ -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((key >> 24) & 3); + auto floor = static_cast((key >> 16) & 0xFF); + auto enemy_type = static_cast(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 QuestMetadata::parse_enemy_exp_overrides(const phosg::JSON& json) { + try { + std::unordered_map 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 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(key_tokens[1]); + } else if (key_tokens.size() == 3) { + floor = stoul(key_tokens[1], nullptr, 0); + enemy_type = phosg::enum_for_name(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())); + } +} diff --git a/src/QuestMetadata.hh b/src/QuestMetadata.hh index f0c68436..b4f44284 100644 --- a/src/QuestMetadata.hh +++ b/src/QuestMetadata.hh @@ -9,10 +9,12 @@ #include #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 enemy_exp_overrides; std::string name; std::string short_description; std::string long_description; + static std::unordered_map 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(difficulty) << 24) | (static_cast(floor) << 16) | static_cast(enemy_type); + } + void assign_default_areas(Version version, Episode episode); void assert_compatible(const QuestMetadata& other) const; phosg::JSON json() const; diff --git a/src/QuestScript.cc b/src/QuestScript.cc index 000e589b..5a10f120 100644 --- a/src/QuestScript.cc +++ b/src/QuestScript.cc @@ -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 diff --git a/src/ReceiveSubcommands.cc b/src/ReceiveSubcommands.cc index 7aa5e145..2d7e0768 100644 --- a/src/ReceiveSubcommands.cc +++ b/src/ReceiveSubcommands.cc @@ -3919,11 +3919,19 @@ static void add_player_exp(shared_ptr c, uint32_t exp, uint16_t from_ene static uint32_t base_exp_for_enemy_type( shared_ptr bp_index, + shared_ptr 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 on_steal_exp_bb(shared_ptr 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 on_enemy_exp_request_bb(shared_ptr 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++) { diff --git a/system/quests/retrieval/q058.json b/system/quests/retrieval/q058.json index a2c97a34..603530a2 100644 --- a/system/quests/retrieval/q058.json +++ b/system/quests/retrieval/q058.json @@ -68,4 +68,19 @@ // clearing the 0x02 bit of AllowedDropModes). // "AllowedDropModes": 0x1D, // "DefaultDropMode": "SERVER_PRIVATE", + + // Quests may override enemies' stats, which may result in a different amount + // of experience than the enemy would give by default. If the quest uses the + // set_enemy_physical_data opcode (or get_physical_data in qedit), then the + // EXP values should be set in this dictionary. Each entry here applies to a + // specific enemy type in a specific difficulty level on a specific floor (or + // on all floors). + // "EnemyEXPOverrides": { + // // Key is like "Difficulty:Floor:EnemyType" or "Difficulty:EnemyType", + // // where floor is a decimal or hexadecimal integer and EnemyType matches + // // one of the values in the EnemyType enum in EnemyType.hh. For example: + // "Normal:1:GOBOOMA": 100, // Normal difficulty, floor 1 (Forest 1) only + // "VeryHard:0x0B:DRAGON": 10000, // Very Hard difficulty, floor 11 (Dragon) + // "Ultimate:DELSABER": 200, // Ultimate difficulty, all floors + // }, }