diff --git a/assets/cutscenes/battletest.jsonc b/assets/cutscenes/battletest.jsonc index 22b5de51..cf39d091 100644 --- a/assets/cutscenes/battletest.jsonc +++ b/assets/cutscenes/battletest.jsonc @@ -4,17 +4,13 @@ "type": "BATTLE_START", "enemies": [ { - "stats": { - "attack": 8, "defense": 4, "magic": 0, "speed": 5, "luck": 2, - "healthMax": 30, "mpMax": 0 - }, + "attack": 8, "defense": 4, "magic": 0, "speed": 5, "luck": 2, + "healthMax": 30, "mpMax": 0, "abilities": ["fire"] }, { - "stats": { - "attack": 6, "defense": 3, "magic": 0, "speed": 7, "luck": 2, - "healthMax": 20, "mpMax": 0 - } + "attack": 6, "defense": 3, "magic": 0, "speed": 7, "luck": 2, + "healthMax": 20, "mpMax": 0 } ] } diff --git a/src/dusk/rpg/battle/fighter/battlefighter.c b/src/dusk/rpg/battle/fighter/battlefighter.c index d2b9e385..27ebe06f 100644 --- a/src/dusk/rpg/battle/fighter/battlefighter.c +++ b/src/dusk/rpg/battle/fighter/battlefighter.c @@ -9,6 +9,161 @@ #include "assert/assert.h" #include "util/memory.h" +errorret_t battleFighterParseStatField( + yyjson_val *obj, + const char_t *key, + uint16_t *out +) { + yyjson_val *val = yyjson_obj_get(obj, key); + if(!yyjson_is_int(val)) { + errorThrow("Fighter: missing/invalid '%s'", key); + } + + const int64_t num = yyjson_get_sint(val); + if(num < 0 || num > UINT16_MAX) { + errorThrow("Fighter: '%s' must be between 0 and %u", key, UINT16_MAX); + } + + *out = (uint16_t)num; + errorOk(); +} + +errorret_t battleFighterParseOptionalIntField( + yyjson_val *obj, + const char_t *key, + const int32_t defaultValue, + int32_t *out +) { + yyjson_val *val = yyjson_obj_get(obj, key); + if(val == NULL) { + *out = defaultValue; + errorOk(); + } + + if(!yyjson_is_int(val)) errorThrow("Fighter: invalid '%s'", key); + *out = (int32_t)yyjson_get_sint(val); + errorOk(); +} + +errorret_t battleFighterParse(battlefighter_t *fighter, yyjson_val *obj) { + assertNotNull(fighter, "Fighter cannot be NULL"); + assertNotNull(obj, "JSON object cannot be NULL"); + + int32_t teamNum; + errorChain(battleFighterParseOptionalIntField( + obj, "team", BATTLE_FIGHTER_TEAM_ALLY, &teamNum + )); + if(teamNum < 0 || teamNum >= BATTLE_FIGHTER_TEAM_COUNT) { + errorThrow("Fighter: invalid 'team'"); + } + + int32_t controllerNum; + errorChain(battleFighterParseOptionalIntField( + obj, "controller", BATTLE_FIGHTER_CONTROLLER_PLAYER, &controllerNum + )); + if(controllerNum < 0 || controllerNum >= BATTLE_FIGHTER_CONTROLLER_COUNT) { + errorThrow("Fighter: invalid 'controller'"); + } + + battlefighterstats_t stats; + errorChain(battleFighterParseStatField(obj, "attack", &stats.attack)); + errorChain(battleFighterParseStatField(obj, "defense", &stats.defense)); + errorChain(battleFighterParseStatField(obj, "magic", &stats.magic)); + errorChain(battleFighterParseStatField(obj, "speed", &stats.speed)); + errorChain(battleFighterParseStatField(obj, "luck", &stats.luck)); + errorChain(battleFighterParseStatField(obj, "healthMax", &stats.healthMax)); + errorChain(battleFighterParseStatField(obj, "mpMax", &stats.mpMax)); + + battleFighterInit( + fighter, (battlefighterteam_t)teamNum, + (battlefightercontroller_t)controllerNum, stats + ); + + int32_t statusNum; + errorChain(battleFighterParseOptionalIntField( + obj, "status", BATTLE_FIGHTER_STATUS_NORMAL, &statusNum + )); + if(statusNum < 0 || statusNum >= BATTLE_FIGHTER_STATUS_COUNT) { + errorThrow("Fighter: invalid 'status'"); + } + fighter->status = (battlefighterstatus_t)statusNum; + + int32_t health; + errorChain( + battleFighterParseOptionalIntField(obj, "health", stats.healthMax, &health) + ); + if(health < 0 || health > stats.healthMax) { + errorThrow("Fighter: 'health' out of range"); + } + fighter->stats.health = (uint16_t)health; + + int32_t mp; + errorChain(battleFighterParseOptionalIntField(obj, "mp", stats.mpMax, &mp)); + if(mp < 0 || mp > stats.mpMax) { + errorThrow("Fighter: 'mp' out of range"); + } + fighter->stats.mp = (uint16_t)mp; + + yyjson_val *abilities = yyjson_obj_get(obj, "abilities"); + if(abilities != NULL) { + if( + !yyjson_is_arr(abilities) || + yyjson_arr_size(abilities) > BATTLE_FIGHTER_ABILITY_COUNT_MAX + ) { + errorThrow( + "Fighter: 'abilities' must have 0-%u entries", + BATTLE_FIGHTER_ABILITY_COUNT_MAX + ); + } + + yyjson_arr_iter iter = yyjson_arr_iter_with(abilities); + yyjson_val *abilityVal; + while((abilityVal = yyjson_arr_iter_next(&iter)) != NULL) { + const char_t *name = yyjson_get_str(abilityVal); + battlemoveid_t ability = + name == NULL ? BATTLE_MOVE_ID_NULL : battleMoveGetIdByName(name); + if(ability == BATTLE_MOVE_ID_NULL) { + errorThrow("Fighter: invalid entry in 'abilities'"); + } + battleFighterAbilityAdd(fighter, ability); + } + } + + errorOk(); +} + +yyjson_mut_val *battleFighterSerialize( + const battlefighter_t *fighter, + yyjson_mut_doc *doc +) { + assertNotNull(fighter, "Fighter cannot be NULL"); + assertNotNull(doc, "Doc cannot be NULL"); + + yyjson_mut_val *obj = yyjson_mut_obj(doc); + + yyjson_mut_obj_add_uint(doc, obj, "team", fighter->team); + yyjson_mut_obj_add_uint(doc, obj, "controller", fighter->controller); + yyjson_mut_obj_add_uint(doc, obj, "status", fighter->status); + + yyjson_mut_obj_add_uint(doc, obj, "attack", fighter->stats.attack); + yyjson_mut_obj_add_uint(doc, obj, "defense", fighter->stats.defense); + yyjson_mut_obj_add_uint(doc, obj, "magic", fighter->stats.magic); + yyjson_mut_obj_add_uint(doc, obj, "speed", fighter->stats.speed); + yyjson_mut_obj_add_uint(doc, obj, "luck", fighter->stats.luck); + yyjson_mut_obj_add_uint(doc, obj, "healthMax", fighter->stats.healthMax); + yyjson_mut_obj_add_uint(doc, obj, "mpMax", fighter->stats.mpMax); + yyjson_mut_obj_add_uint(doc, obj, "health", fighter->stats.health); + yyjson_mut_obj_add_uint(doc, obj, "mp", fighter->stats.mp); + + yyjson_mut_val *abilities = yyjson_mut_arr(doc); + for(uint8_t i = 0; i < fighter->abilityCount; i++) { + yyjson_mut_arr_add_str(doc, abilities, BATTLE_MOVES[fighter->abilities[i]].name); + } + yyjson_mut_obj_add_val(doc, obj, "abilities", abilities); + + return obj; +} + void battleFighterInit( battlefighter_t *fighter, const battlefighterteam_t team, diff --git a/src/dusk/rpg/battle/fighter/battlefighter.h b/src/dusk/rpg/battle/fighter/battlefighter.h index 46482373..447f9772 100644 --- a/src/dusk/rpg/battle/fighter/battlefighter.h +++ b/src/dusk/rpg/battle/fighter/battlefighter.h @@ -7,6 +7,7 @@ #pragma once #include "error/error.h" +#include "yyjson.h" #include "rpg/battle/move/battlemove.h" #include "rpg/battle/fighter/battlefighterstatus.h" #include "rpg/battle/fighter/battlefighterteam.h" @@ -45,6 +46,42 @@ void battleFighterInit( const battlefighterstats_t stats ); +/** + * Generic battlefighter_t parser, shared by every place that builds a + * fighter from JSON (party.jsonc roster members, BATTLE_START enemy + * definitions, save-file party members) - see battleFighterSerialize for + * the inverse. Reads 'team'/'controller'/'status' as ints (each optional, + * defaulting to ALLY/PLAYER/NORMAL respectively - a caller that needs a + * different team/controller, e.g. BATTLE_START's enemies, sets those + * fields on fighter after this returns), 'attack'/'defense'/'magic'/ + * 'speed'/'luck'/'healthMax'/'mpMax' as required ints in [0, UINT16_MAX], + * 'health'/'mp' as optional ints defaulting to healthMax/mpMax (a fresh + * fighter), and an optional 'abilities' array of move name strings. Does + * not touch fighter->id - set it (or leave it) before calling, same as + * battleFighterInit. + * + * @param fighter Pointer to the fighter to fill in. + * @param obj The JSON object to read fields from. + * @return Error if a required field is missing/invalid, or an abilities + * entry doesn't name a real move. + */ +errorret_t battleFighterParse(battlefighter_t *fighter, yyjson_val *obj); + +/** + * Inverse of battleFighterParse: builds a new JSON object (not yet + * attached anywhere - the caller attaches it, e.g. via + * yyjson_mut_arr_add_val/yyjson_mut_obj_add_val) with every field + * battleFighterParse reads except id (see its doc comment for why). + * + * @param fighter The fighter to serialize. + * @param doc The mutable document to allocate the object in. + * @return The newly built object. + */ +yyjson_mut_val *battleFighterSerialize( + const battlefighter_t *fighter, + yyjson_mut_doc *doc +); + /** * Returns true if the fighter is in a state where it can still act (i.e. * is not dead). diff --git a/src/dusk/rpg/battle/party.c b/src/dusk/rpg/battle/party.c index ab36b60f..016e4ca3 100644 --- a/src/dusk/rpg/battle/party.c +++ b/src/dusk/rpg/battle/party.c @@ -13,43 +13,6 @@ #include "asset/asset.h" #include "asset/loader/assetloader.h" -errorret_t partyParseStatField( - yyjson_val *obj, - const char_t *key, - const uint16_t min, - uint16_t *out -) { - yyjson_val *val = yyjson_obj_get(obj, key); - if(!yyjson_is_int(val)) { - errorThrow("Party stats: missing/invalid '%s'", key); - } - - const int64_t num = yyjson_get_sint(val); - if(num < min || num > UINT16_MAX) { - errorThrow( - "Party stats: '%s' must be between %u and %u", key, min, UINT16_MAX - ); - } - - *out = (uint16_t)num; - errorOk(); -} - -errorret_t partyParseStats(yyjson_val *obj, battlefighterstats_t *out) { - assertNotNull(obj, "JSON object cannot be NULL"); - assertNotNull(out, "Destination stats cannot be NULL"); - - errorChain(partyParseStatField(obj, "attack", 0, &out->attack)); - errorChain(partyParseStatField(obj, "defense", 0, &out->defense)); - errorChain(partyParseStatField(obj, "magic", 0, &out->magic)); - errorChain(partyParseStatField(obj, "speed", 0, &out->speed)); - errorChain(partyParseStatField(obj, "luck", 0, &out->luck)); - errorChain(partyParseStatField(obj, "healthMax", 1, &out->healthMax)); - errorChain(partyParseStatField(obj, "mpMax", 0, &out->mpMax)); - - errorOk(); -} - errorret_t partyInit(party_t *party) { assertNotNull(party, "Party cannot be NULL"); @@ -96,21 +59,15 @@ errorret_t partyInit(party_t *party) { ); } - battlefighterstats_t stats; - errorret_t statsResult = partyParseStats(memberObj, &stats); - if(errorIsNotOk(statsResult)) { - assetUnlockEntry(entry); - errorChain(statsResult); - } - stringCopy(party->keys[idx], yyjson_get_str(key), PARTY_KEY_LENGTH_MAX - 1); battlefighter_t *member = &party->members[idx]; member->id = idx; - battleFighterInit( - member, BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, - stats - ); + errorret_t parseResult = battleFighterParse(member, memberObj); + if(errorIsNotOk(parseResult)) { + assetUnlockEntry(entry); + errorChain(parseResult); + } idx++; } diff --git a/src/dusk/rpg/battle/party.h b/src/dusk/rpg/battle/party.h index 873f41bb..8feedaef 100644 --- a/src/dusk/rpg/battle/party.h +++ b/src/dusk/rpg/battle/party.h @@ -25,12 +25,12 @@ typedef struct { * Initializes party with an empty order, then seeds every roster slot * directly from party.jsonc's "members" object (each key/value pair fills * the next members[]/keys[] slot in the object's iteration order, key - * copied into keys[] and value parsed via partyParseStats/ - * battleFighterInit) - unlike partyAddMember, this never touches order - * itself except for one placeholder default: if party.jsonc defines at - * least one member, order[0] is set to 0 (the first member starts active - * in the first party position). Replace this default once there's a real - * starting-party/character-creation flow. + * copied into keys[] and value parsed via battleFighterParse) - unlike + * partyAddMember, this never touches order itself except for one + * placeholder default: if party.jsonc defines at least one member, + * order[0] is set to 0 (the first member starts active in the first party + * position). Replace this default once there's a real starting-party/ + * character-creation flow. * * @param party The party to initialize. * @return Error if party.jsonc fails to load/parse, defines more members @@ -58,19 +58,6 @@ uint8_t partyGetMemberIndexByKey(const char_t *key); */ const char_t *partyGetMemberKey(const uint8_t memberIndex); -/** - * Parses a set of base combat stats (attack/defense/magic/speed/luck/ - * healthMax/mpMax) out of a JSON object. Every field is required - there - * is no implicit default - and must be an integer in [0, UINT16_MAX], - * except healthMax which must be at least 1 (a fighter with 0 max health - * makes no sense). - * - * @param obj The JSON object to read fields from. - * @param out Destination stats, filled in on success. - * @return Error if a field is missing, not an integer, or out of range. - */ -errorret_t partyParseStats(yyjson_val *obj, battlefighterstats_t *out); - /** * Checks whether memberIndex currently occupies a slot in the current save * slot's party order (i.e. is actively in the party right now). diff --git a/src/dusk/rpg/cutscene/item/battle/cutscenebattlestart.c b/src/dusk/rpg/cutscene/item/battle/cutscenebattlestart.c index 439f20b8..d136db47 100644 --- a/src/dusk/rpg/cutscene/item/battle/cutscenebattlestart.c +++ b/src/dusk/rpg/cutscene/item/battle/cutscenebattlestart.c @@ -34,17 +34,8 @@ void cutsceneBattleStartStart( sizeof(CUTSCENE_BATTLE_START_ENEMY_POOL) ); for(uint8_t i = 0; i < config->enemyCount; i++) { - const cutscenebattlestartenemy_t *enemyConfig = &config->enemies[i]; battlefighter_t *enemy = &CUTSCENE_BATTLE_START_ENEMY_POOL[i]; - - battleFighterInit( - enemy, BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, - enemyConfig->stats - ); - for(uint8_t a = 0; a < enemyConfig->abilityCount; a++) { - battleFighterAbilityAdd(enemy, enemyConfig->abilities[a]); - } - + *enemy = config->enemies[i].fighter; fighters[i] = enemy; } @@ -88,54 +79,14 @@ errorret_t cutsceneBattleStartLoad( while((enemyVal = yyjson_arr_iter_next(&iter)) != NULL) { cutscenebattlestartenemy_t *enemy = &item->battleStart.enemies[e]; - yyjson_val *stats = yyjson_obj_get(enemyVal, "stats"); - if(!yyjson_is_obj(stats)) { - errorThrow( - "Cutscene item (BATTLE_START): enemies[%u] is missing 'stats'", - (uint32_t)e - ); - } - enemy->stats.attack = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "attack")); - enemy->stats.defense = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "defense")); - enemy->stats.magic = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "magic")); - enemy->stats.speed = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "speed")); - enemy->stats.luck = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "luck")); - enemy->stats.healthMax = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "healthMax")); - enemy->stats.mpMax = (uint16_t)yyjson_get_int(yyjson_obj_get(stats, "mpMax")); + errorret_t parseResult = battleFighterParse(&enemy->fighter, enemyVal); + if(errorIsNotOk(parseResult)) errorChain(parseResult); - yyjson_val *abilities = yyjson_obj_get(enemyVal, "abilities"); - if(abilities != NULL) { - size_t abilityCount = yyjson_arr_size(abilities); - if( - !yyjson_is_arr(abilities) || - abilityCount > BATTLE_FIGHTER_ABILITY_COUNT_MAX - ) { - errorThrow( - "Cutscene item (BATTLE_START): enemies[%u].abilities must have " - "0-%u entries", - (uint32_t)e, (uint32_t)BATTLE_FIGHTER_ABILITY_COUNT_MAX - ); - } - - yyjson_arr_iter abilityIter = yyjson_arr_iter_with(abilities); - yyjson_val *abilityVal; - size_t a = 0; - while((abilityVal = yyjson_arr_iter_next(&abilityIter)) != NULL) { - const char_t *name = yyjson_get_str(abilityVal); - battlemoveid_t ability = name == NULL - ? BATTLE_MOVE_ID_NULL : battleMoveGetIdByName(name); - if(ability == BATTLE_MOVE_ID_NULL) { - errorThrow( - "Cutscene item (BATTLE_START): enemies[%u].abilities[%u] is " - "invalid", - (uint32_t)e, (uint32_t)a - ); - } - enemy->abilities[a] = ability; - a++; - } - enemy->abilityCount = (uint8_t)abilityCount; - } + // Enemy JSON never specifies team/controller (battleFighterParse just + // defaulted them to ALLY/PLAYER) - BATTLE_START enemies are always + // ENEMY/AI. + enemy->fighter.team = BATTLE_FIGHTER_TEAM_ENEMY; + enemy->fighter.controller = BATTLE_FIGHTER_CONTROLLER_AI; e++; } diff --git a/src/dusk/rpg/cutscene/item/battle/cutscenebattlestart.h b/src/dusk/rpg/cutscene/item/battle/cutscenebattlestart.h index e4e2926c..c6024903 100644 --- a/src/dusk/rpg/cutscene/item/battle/cutscenebattlestart.h +++ b/src/dusk/rpg/cutscene/item/battle/cutscenebattlestart.h @@ -13,9 +13,11 @@ #define CUTSCENE_BATTLE_START_ENEMY_COUNT_MAX 4 typedef struct { - battlefighterstats_t stats; - battlemoveid_t abilities[BATTLE_FIGHTER_ABILITY_COUNT_MAX]; - uint8_t abilityCount; + // Fully parsed at Load time via battleFighterParse (team/controller + // forced to ENEMY/AI right after, since enemy JSON only ever supplies + // stats/abilities) - Start splices this directly into + // CUTSCENE_BATTLE_START_ENEMY_POOL, no further init needed. + battlefighter_t fighter; } cutscenebattlestartenemy_t; typedef struct { diff --git a/src/dusk/save/slot/saveslotver1.c b/src/dusk/save/slot/saveslotver1.c index 753e36df..7774dc3b 100644 --- a/src/dusk/save/slot/saveslotver1.c +++ b/src/dusk/save/slot/saveslotver1.c @@ -26,21 +26,9 @@ errorret_t saveSlotVer1WriteJSON( yyjson_mut_val *partyMembersArr = yyjson_mut_arr(doc); for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) { - battlefighter_t *member = &slot->partyMembers[i]; - writeArrayObject(partyMembersArr); - writeUInt32("id", member->id); - writeUInt32("status", member->status); - writeUInt32("team", member->team); - writeUInt32("controller", member->controller); - writeUInt32("health", member->stats.health); - writeUInt32("healthMax", member->stats.healthMax); - writeUInt32("mp", member->stats.mp); - writeUInt32("mpMax", member->stats.mpMax); - writeUInt32("statAttack", member->stats.attack); - writeUInt32("statDefense", member->stats.defense); - writeUInt32("statMagic", member->stats.magic); - writeUInt32("statSpeed", member->stats.speed); - writeUInt32("statLuck", member->stats.luck); + yyjson_mut_val *memberObj = + battleFighterSerialize(&slot->partyMembers[i], doc); + yyjson_mut_arr_add_val(partyMembersArr, memberObj); } writeValue("partyMembers", partyMembersArr); @@ -84,24 +72,8 @@ errorret_t saveSlotVer1ReadJSON(saveslotver1_t *slot, yyjson_val *object) { partyMembersArr, memberIdx, memberLen, memberObj ) { battlefighter_t *member = &slot->partyMembers[memberIdx]; - // Shadows the outer `object` so readUInt32() below (which hardcodes - // that name) reads from this member's object instead. - yyjson_val *object = memberObj; - readUInt32("id", member->id, 0); - readUInt32("status", member->status, BATTLE_FIGHTER_STATUS_NULL); - readUInt32("team", member->team, BATTLE_FIGHTER_TEAM_ALLY); - readUInt32( - "controller", member->controller, BATTLE_FIGHTER_CONTROLLER_PLAYER - ); - readUInt32("health", member->stats.health, 0); - readUInt32("healthMax", member->stats.healthMax, 0); - readUInt32("mp", member->stats.mp, 0); - readUInt32("mpMax", member->stats.mpMax, 0); - readUInt32("statAttack", member->stats.attack, 0); - readUInt32("statDefense", member->stats.defense, 0); - readUInt32("statMagic", member->stats.magic, 0); - readUInt32("statSpeed", member->stats.speed, 0); - readUInt32("statLuck", member->stats.luck, 0); + member->id = (uint8_t)memberIdx; + errorChain(battleFighterParse(member, memberObj)); } readUInt8Array( diff --git a/src/dusk/save/slot/saveslotver1.h b/src/dusk/save/slot/saveslotver1.h index 0433c8e2..41acba00 100644 --- a/src/dusk/save/slot/saveslotver1.h +++ b/src/dusk/save/slot/saveslotver1.h @@ -24,6 +24,16 @@ // keyed by flag name (not position), so its on-disk shape is decoupled from // STORY_FLAG_COUNT_MAX entirely; a save simply loses whatever flags no // longer resolve by name (see storyGetFlagByName) if that cap ever shrinks. +// +// partyMembers is a THIRD exception, deliberately broken pre-release: its +// per-member shape now comes from battleFighterSerialize/battleFighterParse +// (rpg/battle/fighter/battlefighter.h) - the shared fighter JSON format +// used everywhere else (party.jsonc, BATTLE_START enemies) - instead of its +// own hand-rolled field set. This is a real on-disk break (different key +// names, no more "statAttack" etc.) with no migration - an old v1 save +// simply reads back as corrupt. Acceptable only because nothing has +// shipped yet; don't repeat this once real saves exist without a version +// bump instead. #define SAVE_SLOT_VER1_NAME_LENGTH 8 #define SAVE_SLOT_VER1_MAP_NAME_MAX 10