Add shared battleFighterParse/Serialize, use it everywhere a fighter is (de)serialized

Replaces three independently hand-rolled battlefighter_t JSON readers/
writers (party.jsonc roster loading, BATTLE_START enemy definitions, and
the v1 save-file party member format) with one generic pair in
battlefighter.c: battleFighterParse(fighter, obj) and its inverse
battleFighterSerialize(fighter, doc). Covers team/controller/status
(optional, default ally/player/normal), the 7 base stats (required),
current health/mp (optional, default to max), and an optional abilities
name array.

- party.c: partyInit now parses each roster member via battleFighterParse
  directly, dropping the old partyParseStats/partyParseStatField.
- cutscenebattlestart.c/.h: cutscenebattlestartenemy_t now embeds a full
  battlefighter_t, parsed once at Load time; Start just copies it into the
  enemy pool instead of re-initializing. battletest.jsonc's enemies are
  now flat (dropped the "stats" wrapper) to match.
- saveslotver1.c/.h: rewritten in place to use the shared parser/
  serializer, changing the v1 on-disk field names/shape (no migration -
  acceptable pre-release, documented in saveslotver1.h).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-22 14:20:45 -05:00
co-authored by Claude Sonnet 5
parent 8dbd3e1dd2
commit a4ca718070
9 changed files with 235 additions and 168 deletions
+4 -8
View File
@@ -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
}
]
}
+155
View File
@@ -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,
@@ -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).
+5 -48
View File
@@ -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++;
}
+6 -19
View File
@@ -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).
@@ -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++;
}
@@ -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 {
+5 -33
View File
@@ -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(
+10
View File
@@ -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