From 7a9a2b9832ab0bf5593ff7ed4c36a13c8f01d010 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Mon, 21 Sep 2026 22:27:46 -0500 Subject: [PATCH] Add order-based party roster, back-navigation in battle selection, fix win-state cutscene leak - Battle selection menu can now go back a fighter to re-decide their move, and battle win/loss no longer leaks the round's remaining queued move cutscenes into the ending state (cutsceneSystemDiscardQueued). - Party roster is now driven by party.jsonc: every defined member always exists (partyInit), while party->order alone determines who's currently active - partyAddMember/partyRemoveMember (and their cutscene items) now activate/deactivate an existing roster member by index instead of creating/destroying one, with removal compacting order to stay packed. - New PARTY_ADD_MEMBER/PARTY_REMOVE_MEMBER/PARTY_HEAL_MEMBER/PARTY_HEAL_ALL cutscene items. - saveSlotInit/partyInit now return errorret_t and take their target struct directly (saveslot_t*/party_t*) instead of implicitly operating on SAVE.slot; saveSlotReadJSON resets through saveSlotInit rather than duplicating the reset logic. - Test coverage: stub the JSON asset loader in save test fixtures so partyInit's real party.jsonc load runs against an in-memory doc instead of hanging with no asset system initialized. Co-Authored-By: Claude Sonnet 5 --- assets/party.jsonc | 13 ++ src/dusk/rpg/battle/party.c | 176 +++++++++++++++--- src/dusk/rpg/battle/party.h | 86 +++++++-- .../rpg/battle/state/battlestateselection.c | 22 +++ .../rpg/battle/state/battlestateselection.h | 11 +- src/dusk/rpg/cutscene/cutscenesystem.c | 11 ++ src/dusk/rpg/cutscene/cutscenesystem.h | 11 ++ src/dusk/rpg/cutscene/item/CMakeLists.txt | 1 + .../item/battle/cutscenebattlepostmove.c | 6 + src/dusk/rpg/cutscene/item/cutsceneitem.c | 29 +++ src/dusk/rpg/cutscene/item/cutsceneitem.h | 7 + src/dusk/rpg/cutscene/item/cutsceneitembase.h | 6 + .../rpg/cutscene/item/party/CMakeLists.txt | 12 ++ .../item/party/cutscenepartyaddmember.c | 39 ++++ .../item/party/cutscenepartyaddmember.h | 51 +++++ .../item/party/cutscenepartyhealall.c | 23 +++ .../item/party/cutscenepartyhealall.h | 33 ++++ .../item/party/cutscenepartyhealmember.c | 39 ++++ .../item/party/cutscenepartyhealmember.h | 50 +++++ .../item/party/cutscenepartyremovemember.c | 39 ++++ .../item/party/cutscenepartyremovemember.h | 51 +++++ src/dusk/rpg/rpg.c | 1 - src/dusk/save/save.c | 8 +- src/dusk/save/slot/saveslot.c | 43 +++-- src/dusk/save/slot/saveslot.h | 27 +-- src/dusk/ui/dialog/save/uiselectsave.c | 14 +- src/dusk/ui/screen/battle/uibattlemenu.c | 7 + src/dusk/ui/screen/mainmenu/uimainmenu.c | 8 +- test/save/CMakeLists.txt | 1 + test/save/savetestfixture.c | 52 ++++++ test/save/savetestfixture.h | 18 ++ test/save/test_save.c | 14 +- test/save/test_savedevice.c | 2 +- test/save/test_savedevicelinux.c | 2 +- test/save/test_saveslot.c | 24 ++- 35 files changed, 838 insertions(+), 99 deletions(-) create mode 100644 assets/party.jsonc create mode 100644 src/dusk/rpg/cutscene/item/party/CMakeLists.txt create mode 100644 src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.c create mode 100644 src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.h create mode 100644 src/dusk/rpg/cutscene/item/party/cutscenepartyhealall.c create mode 100644 src/dusk/rpg/cutscene/item/party/cutscenepartyhealall.h create mode 100644 src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.c create mode 100644 src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.h create mode 100644 src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.c create mode 100644 src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.h diff --git a/assets/party.jsonc b/assets/party.jsonc new file mode 100644 index 00000000..c68ff649 --- /dev/null +++ b/assets/party.jsonc @@ -0,0 +1,13 @@ +{ + // Starting party roster, loaded by partyInit into a fresh save slot. + "members": [ + { + "attack": 10, "defense": 5, "magic": 0, "speed": 10, "luck": 0, + "healthMax": 30, "mpMax": 10 + }, + { + "attack": 8, "defense": 6, "magic": 4, "speed": 8, "luck": 1, + "healthMax": 25, "mpMax": 15 + } + ] +} diff --git a/src/dusk/rpg/battle/party.c b/src/dusk/rpg/battle/party.c index a6568fb0..1f2eeb72 100644 --- a/src/dusk/rpg/battle/party.c +++ b/src/dusk/rpg/battle/party.c @@ -9,49 +9,134 @@ #include "assert/assert.h" #include "util/memory.h" #include "save/save.h" +#include "asset/asset.h" +#include "asset/loader/assetloader.h" -void partyInit(void) { - party_t *party = &SAVE.slot.party; +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"); memoryZero(party, sizeof(party_t)); - for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) { - party->members[i].id = i; - } for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) { party->order[i] = PARTY_ORDER_EMPTY; } -} -uint8_t partyGetAvailableMember(void) { - const party_t *party = &SAVE.slot.party; - - for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) { - if(party->members[i].status == BATTLE_FIGHTER_STATUS_NULL) return i; + assetentry_t *entry = assetLock("party.jsonc", ASSET_LOADER_TYPE_JSON, NULL); + errorret_t result = assetRequireLoaded(entry); + if(errorIsNotOk(result)) { + assetUnlockEntry(entry); + errorChain(result); } - return 0xFF; + yyjson_val *root = yyjson_doc_get_root(entry->data.json); + yyjson_val *members = yyjson_obj_get(root, "members"); + if(!yyjson_is_arr(members)) { + assetUnlockEntry(entry); + errorThrow("party.jsonc: missing/invalid 'members' array"); + } + + const size_t memberCount = yyjson_arr_size(members); + if(memberCount > PARTY_MEMBER_COUNT_MAX) { + assetUnlockEntry(entry); + errorThrow( + "party.jsonc: defines more than PARTY_MEMBER_COUNT_MAX (%u) members", + PARTY_MEMBER_COUNT_MAX + ); + } + + size_t idx, max; + yyjson_val *memberObj; + yyjson_arr_foreach(members, idx, max, memberObj) { + battlefighterstats_t stats; + errorret_t statsResult = partyParseStats(memberObj, &stats); + if(errorIsNotOk(statsResult)) { + assetUnlockEntry(entry); + errorChain(statsResult); + } + + battlefighter_t *member = &party->members[idx]; + member->id = (uint8_t)idx; + battleFighterInit( + member, BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, + stats + ); + } + + assetUnlockEntry(entry); + + // TEST: only the first defined member starts active, in the first party + // position - replace once there's a real starting-party/character- + // creation flow. Deliberately separate from the roster seeding above: + // order (not a member's presence in the roster) is what determines who's + // currently in the party. + if(memberCount > 0) party->order[0] = 0; + + errorOk(); } -battlefighter_t *partyAddMember( - const battlefighterstats_t stats -) { +bool_t partyIsMemberActive(const uint8_t memberIndex) { + assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index"); + + const party_t *party = &SAVE.slot.party; + for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) { + if(party->order[i] == memberIndex) return true; + } + + return false; +} + +void partyAddMember(const uint8_t memberIndex) { + assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index"); + party_t *party = &SAVE.slot.party; - - const uint8_t index = partyGetAvailableMember(); - if(index == 0xFF) return NULL; - - battlefighter_t *member = &party->members[index]; - battleFighterInit( - member, BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, stats + assertTrue( + party->members[memberIndex].status != BATTLE_FIGHTER_STATUS_NULL, + "Member does not exist in the roster" ); + if(partyIsMemberActive(memberIndex)) return; + for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) { if(party->order[i] != PARTY_ORDER_EMPTY) continue; - party->order[i] = index; - break; + party->order[i] = memberIndex; + return; } - - return member; } battlefighter_t *partyGetOrderMember(const uint8_t slot) { @@ -72,3 +157,42 @@ void partySetOrder(const uint8_t slot, const uint8_t memberIndex) { SAVE.slot.party.order[slot] = memberIndex; } + +void partyRemoveMember(const uint8_t memberIndex) { + assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index"); + + party_t *party = &SAVE.slot.party; + + uint8_t removedSlot = PARTY_ORDER_EMPTY; + for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) { + if(party->order[i] != memberIndex) continue; + removedSlot = i; + break; + } + if(removedSlot == PARTY_ORDER_EMPTY) return; + + for(uint8_t i = removedSlot; i < PARTY_ACTIVE_SIZE_MAX - 1; i++) { + party->order[i] = party->order[i + 1]; + } + party->order[PARTY_ACTIVE_SIZE_MAX - 1] = PARTY_ORDER_EMPTY; +} + +void partyHealMember(const uint8_t memberIndex) { + assertTrue(memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index"); + + battlefighter_t *member = &SAVE.slot.party.members[memberIndex]; + if(member->status == BATTLE_FIGHTER_STATUS_NULL) return; + + member->status = BATTLE_FIGHTER_STATUS_NORMAL; + member->stats.health = member->stats.healthMax; + member->stats.mp = member->stats.mpMax; +} + +void partyHealAll(void) { + const party_t *party = &SAVE.slot.party; + + for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) { + if(party->members[i].status == BATTLE_FIGHTER_STATUS_NULL) continue; + partyHealMember(i); + } +} diff --git a/src/dusk/rpg/battle/party.h b/src/dusk/rpg/battle/party.h index 367d01d7..9393f748 100644 --- a/src/dusk/rpg/battle/party.h +++ b/src/dusk/rpg/battle/party.h @@ -7,44 +7,69 @@ #pragma once #include "rpg/battle/fighter/battlefighter.h" +#include "yyjson.h" #define PARTY_MEMBER_COUNT_MAX 4 #define PARTY_ACTIVE_SIZE_MAX 3 #define PARTY_ORDER_EMPTY 0xFF typedef struct { + // Every member party.jsonc defines always exists here (seeded by + // partyInit), regardless of whether it's currently active - order (not + // this array, and not a member's status) is what determines who's + // currently in the party. battlefighter_t members[PARTY_MEMBER_COUNT_MAX]; uint8_t order[PARTY_ACTIVE_SIZE_MAX]; // TODO: Whether party members are "front" or "back". } party_t; /** - * Initializes the current save slot's party (SAVE.slot.party) with an - * empty roster and order. + * Initializes party with an empty order, then seeds every roster slot + * directly from party.jsonc's "members" array (index i of the array fills + * members[i] 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 (member 0 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, or if it defines more + * members than PARTY_MEMBER_COUNT_MAX has room for. */ -void partyInit(void); +errorret_t partyInit(party_t *party); /** - * Gets an available (unused) member slot index in the current save slot's - * party. + * 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). * - * @return The index of an available slot, or 0xFF if the party is full. + * @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. */ -uint8_t partyGetAvailableMember(void); +errorret_t partyParseStats(yyjson_val *obj, battlefighterstats_t *out); /** - * Adds a member to the current save slot's party roster in the next - * available slot. Party members are always allies controlled by the - * player. If there is a free active order slot, the new member is placed - * into it. + * Checks whether memberIndex currently occupies a slot in the current save + * slot's party order (i.e. is actively in the party right now). * - * @param stats The member's base combat stats, including healthMax/mpMax. - * @return Pointer to the newly added party member, or NULL if the party is - * already full. + * @param memberIndex The roster member index to check. + * @return True if memberIndex appears anywhere in order. */ -battlefighter_t *partyAddMember( - const battlefighterstats_t stats -); +bool_t partyIsMemberActive(const uint8_t memberIndex); + +/** + * Adds an existing roster member (see partyInit - every roster slot + * always exists once initialized) to the current save slot's active party + * order, in the first free order slot. No-op if memberIndex is already + * active, or if there is no free order slot. + * + * @param memberIndex The roster member index to activate. + */ +void partyAddMember(const uint8_t memberIndex); /** * Gets the roster member currently occupying an active order slot in the @@ -66,3 +91,30 @@ battlefighter_t *partyGetOrderMember(const uint8_t slot); * PARTY_ORDER_EMPTY to clear the slot. */ void partySetOrder(const uint8_t slot, const uint8_t memberIndex); + +/** + * Removes memberIndex from the current save slot's active party order - + * the roster member itself is untouched (it still exists, per partyInit; + * it's simply no longer active) - shifting every later order entry down + * one slot to close the gap, so order stays packed with no holes between + * active members. No-op if memberIndex isn't currently active. + * + * @param memberIndex The roster member index to deactivate. + */ +void partyRemoveMember(const uint8_t memberIndex); + +/** + * Fully restores a roster member's health/mp to their max and clears a + * dead status back to normal - unlike battleFighterHeal, this can revive + * a dead member (there's no combat context here to keep them down). No-op + * if the slot is empty. + * + * @param memberIndex The roster member index to heal. + */ +void partyHealMember(const uint8_t memberIndex); + +/** + * Calls partyHealMember for every occupied slot in the current save + * slot's party roster. + */ +void partyHealAll(void); diff --git a/src/dusk/rpg/battle/state/battlestateselection.c b/src/dusk/rpg/battle/state/battlestateselection.c index 22d08c55..8bd2e2f8 100644 --- a/src/dusk/rpg/battle/state/battlestateselection.c +++ b/src/dusk/rpg/battle/state/battlestateselection.c @@ -115,6 +115,28 @@ void battleStateSelectionPlayerAttack(const uint8_t targetIndex) { BATTLE.stateData.selection.selectionIndex++; } +void battleStateSelectionGoBack(void) { + battlestateselection_t *state = &BATTLE.stateData.selection; + uint8_t index = state->selectionIndex; + + while(index > 0) { + index--; + + const uint8_t fighterIndex = BATTLE.fighterOrder[index]; + battlefighter_t *fighter = BATTLE.fighters[fighterIndex]; + if(fighter == NULL) continue; + if(fighter->controller != BATTLE_FIGHTER_CONTROLLER_PLAYER) continue; + if(!battleStateSelectionFighterHasDecided(fighterIndex)) continue; + + battlemoveaction_t *action = &BATTLE.actionsPending[fighterIndex]; + action->move = BATTLE_MOVE_ID_NULL; + BATTLE.actionsPendingCount--; + + state->selectionIndex = index; + return; + } +} + void battleStateSelectionPlayerFlee(void) { battlefighter_t *fighter = battleStateSelectionGetCurrentFighter(); if(fighter == NULL) return; diff --git a/src/dusk/rpg/battle/state/battlestateselection.h b/src/dusk/rpg/battle/state/battlestateselection.h index c4a7d1ba..3a43439e 100644 --- a/src/dusk/rpg/battle/state/battlestateselection.h +++ b/src/dusk/rpg/battle/state/battlestateselection.h @@ -108,4 +108,13 @@ void battleStateSelectionPlayerAttack(const uint8_t targetIndex); * Ends the battle immediately with BATTLE_STATE_FLED, if the battle is in * BATTLE_STATE_SELECTION and awaiting a decision. */ -void battleStateSelectionPlayerFlee(void); \ No newline at end of file +void battleStateSelectionPlayerFlee(void); + +/** + * Rewinds the selection cursor to the nearest earlier player-controlled + * fighter that has already decided a move this round, clearing its + * pending action so it can be re-decided. AI-decided fighters along the + * way are left alone (skipped over, not rewound onto). No-ops if there's + * no such fighter (e.g. already at the first decision of the round). + */ +void battleStateSelectionGoBack(void); \ No newline at end of file diff --git a/src/dusk/rpg/cutscene/cutscenesystem.c b/src/dusk/rpg/cutscene/cutscenesystem.c index 844d4f69..1b9d55e1 100644 --- a/src/dusk/rpg/cutscene/cutscenesystem.c +++ b/src/dusk/rpg/cutscene/cutscenesystem.c @@ -125,6 +125,17 @@ void cutsceneSystemInsertCutscene(const cutscene_t *cutscene) { cutsceneSystemInsertItems(cutscene->items, cutscene->itemCount); } +void cutsceneSystemDiscardQueued(void) { + assertNotNull( + CUTSCENE_SYSTEM.scene, + "cutsceneSystemDiscardQueued called with no cutscene running" + ); + + // currentItem is always 0 once a cutscene has started (see its doc + // comment) - keep just that one item, drop everything queued after it. + CUTSCENE_SYSTEM.scene->itemCount = 1; +} + void cutsceneSystemLoad(const char_t *file) { assertNotNull(file, "File cannot be NULL"); diff --git a/src/dusk/rpg/cutscene/cutscenesystem.h b/src/dusk/rpg/cutscene/cutscenesystem.h index 3ccdf3fd..8fbe0e04 100644 --- a/src/dusk/rpg/cutscene/cutscenesystem.h +++ b/src/dusk/rpg/cutscene/cutscenesystem.h @@ -156,6 +156,17 @@ void cutsceneSystemInsertItems( */ void cutsceneSystemInsertCutscene(const cutscene_t *cutscene); +/** + * Discards every item still queued behind the currently-running one, + * without disturbing that item's own progress - e.g. so a callback that + * decides the rest of a batch-spliced sequence is now moot (a battle + * ending mid-round, with other fighters' still-queued move cutscenes and + * the round's trailing state transition left behind) can drop it before + * splicing in its own follow-up items, instead of leaving it to run + * afterward against torn-down state. + */ +void cutsceneSystemDiscardQueued(void); + /** * Loads and immediately starts a cutscene asset by file name. * diff --git a/src/dusk/rpg/cutscene/item/CMakeLists.txt b/src/dusk/rpg/cutscene/item/CMakeLists.txt index 42868c1b..bffc8bf0 100755 --- a/src/dusk/rpg/cutscene/item/CMakeLists.txt +++ b/src/dusk/rpg/cutscene/item/CMakeLists.txt @@ -21,3 +21,4 @@ add_subdirectory(save) add_subdirectory(audio) add_subdirectory(json) add_subdirectory(story) +add_subdirectory(party) diff --git a/src/dusk/rpg/cutscene/item/battle/cutscenebattlepostmove.c b/src/dusk/rpg/cutscene/item/battle/cutscenebattlepostmove.c index 9c1136dc..965369c3 100644 --- a/src/dusk/rpg/cutscene/item/battle/cutscenebattlepostmove.c +++ b/src/dusk/rpg/cutscene/item/battle/cutscenebattlepostmove.c @@ -35,6 +35,12 @@ void cutsceneBattlePostMoveStart( BATTLE.result = allyAlive ? BATTLE_RESULT_WIN : BATTLE_RESULT_LOSS; + // Drop the rest of this round's already-spliced items (other fighters' + // still-pending move cutscenes, the trailing BATTLE_SET_STATE(PRE_ROUND) - + // see battleStateExecutingInit) - the battle is over, so none of that + // should run against the WON/LOST/ENDING transition about to replace it. + cutsceneSystemDiscardQueued(); + const cutsceneitem_t items[] = { { .type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE, diff --git a/src/dusk/rpg/cutscene/item/cutsceneitem.c b/src/dusk/rpg/cutscene/item/cutsceneitem.c index b6f44ad7..123c7357 100644 --- a/src/dusk/rpg/cutscene/item/cutsceneitem.c +++ b/src/dusk/rpg/cutscene/item/cutsceneitem.c @@ -371,6 +371,35 @@ cutsceneitemcallbacks_t CUTSCENE_ITEM_CALLBACKS[CUTSCENE_ITEM_TYPE_COUNT] = { .update = cutsceneStoryWaitFlagUpdate, .jsonName = "STORY_WAIT_FLAG", .load = cutsceneStoryWaitFlagLoad + }, + + // Party + [CUTSCENE_ITEM_TYPE_PARTY_ADD_MEMBER] = { + .init = cutscenePartyAddMemberStart, + .update = cutscenePartyAddMemberUpdate, + .jsonName = "PARTY_ADD_MEMBER", + .load = cutscenePartyAddMemberLoad + }, + + [CUTSCENE_ITEM_TYPE_PARTY_REMOVE_MEMBER] = { + .init = cutscenePartyRemoveMemberStart, + .update = cutscenePartyRemoveMemberUpdate, + .jsonName = "PARTY_REMOVE_MEMBER", + .load = cutscenePartyRemoveMemberLoad + }, + + [CUTSCENE_ITEM_TYPE_PARTY_HEAL_MEMBER] = { + .init = cutscenePartyHealMemberStart, + .update = cutscenePartyHealMemberUpdate, + .jsonName = "PARTY_HEAL_MEMBER", + .load = cutscenePartyHealMemberLoad + }, + + [CUTSCENE_ITEM_TYPE_PARTY_HEAL_ALL] = { + .init = cutscenePartyHealAllStart, + .update = cutscenePartyHealAllUpdate, + .jsonName = "PARTY_HEAL_ALL", + .load = cutsceneItemLoadNoop } }; diff --git a/src/dusk/rpg/cutscene/item/cutsceneitem.h b/src/dusk/rpg/cutscene/item/cutsceneitem.h index 5d53190a..84b09277 100644 --- a/src/dusk/rpg/cutscene/item/cutsceneitem.h +++ b/src/dusk/rpg/cutscene/item/cutsceneitem.h @@ -59,6 +59,10 @@ #include "audio/cutsceneaudioset.h" #include "story/cutscenestorysetflag.h" #include "story/cutscenestorywaitflag.h" +#include "party/cutscenepartyaddmember.h" +#include "party/cutscenepartyremovemember.h" +#include "party/cutscenepartyhealmember.h" +#include "party/cutscenepartyhealall.h" typedef struct cutscene_s cutscene_t; @@ -144,6 +148,9 @@ struct cutsceneitem_s { cutsceneuishow_t uiShow; cutscenestorysetflag_t storySetFlag; cutscenestorywaitflag_t storyWaitFlag; + cutscenepartyaddmember_t partyAddMember; + cutscenepartyremovemember_t partyRemoveMember; + cutscenepartyhealmember_t partyHealMember; }; }; diff --git a/src/dusk/rpg/cutscene/item/cutsceneitembase.h b/src/dusk/rpg/cutscene/item/cutsceneitembase.h index 107523d8..b53bcbbf 100644 --- a/src/dusk/rpg/cutscene/item/cutsceneitembase.h +++ b/src/dusk/rpg/cutscene/item/cutsceneitembase.h @@ -86,6 +86,12 @@ typedef enum { CUTSCENE_ITEM_TYPE_STORY_SET_FLAG, CUTSCENE_ITEM_TYPE_STORY_WAIT_FLAG, + // Party + CUTSCENE_ITEM_TYPE_PARTY_ADD_MEMBER, + CUTSCENE_ITEM_TYPE_PARTY_REMOVE_MEMBER, + CUTSCENE_ITEM_TYPE_PARTY_HEAL_MEMBER, + CUTSCENE_ITEM_TYPE_PARTY_HEAL_ALL, + CUTSCENE_ITEM_TYPE_COUNT } cutsceneitemtype_t; diff --git a/src/dusk/rpg/cutscene/item/party/CMakeLists.txt b/src/dusk/rpg/cutscene/item/party/CMakeLists.txt new file mode 100644 index 00000000..6f383c3e --- /dev/null +++ b/src/dusk/rpg/cutscene/item/party/CMakeLists.txt @@ -0,0 +1,12 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + cutscenepartyaddmember.c + cutscenepartyremovemember.c + cutscenepartyhealmember.c + cutscenepartyhealall.c +) diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.c b/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.c new file mode 100644 index 00000000..528453f5 --- /dev/null +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.c @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "rpg/cutscene/item/cutsceneitem.h" +#include "rpg/battle/party.h" +#include "rpg/cutscene/item/json/cutscenejsonfield.h" + +void cutscenePartyAddMemberStart( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + partyAddMember(item->partyAddMember.memberIndex); +} + +bool_t cutscenePartyAddMemberUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + return true; +} + +errorret_t cutscenePartyAddMemberLoad( + yyjson_val *itemObj, + cutsceneitem_t *item +) { + const int32_t memberIndex = cutsceneJsonGetInt(itemObj, "memberIndex", -1); + if(memberIndex < 0 || memberIndex >= PARTY_MEMBER_COUNT_MAX) { + errorThrow( + "Cutscene item (PARTY_ADD_MEMBER): invalid/missing 'memberIndex'" + ); + } + item->partyAddMember.memberIndex = (uint8_t)memberIndex; + + errorOk(); +} diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.h b/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.h new file mode 100644 index 00000000..384d4fe7 --- /dev/null +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyaddmember.h @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "rpg/cutscene/item/cutsceneitembase.h" + +typedef struct { + uint8_t memberIndex; +} cutscenepartyaddmember_t; + +/** + * Starts a PARTY_ADD_MEMBER item, activating the configured existing + * roster member into the current save slot's active party order (see + * partyAddMember). No-op if already active or there's no free order slot. + * + * @param item The cutscene item. + * @param data Runtime data storage. + */ +void cutscenePartyAddMemberStart( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); + +/** + * Updates a PARTY_ADD_MEMBER item (always completes immediately). + * + * @param item The cutscene item. + * @param data Runtime data storage. + * @returns true always. + */ +bool_t cutscenePartyAddMemberUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); + +/** + * Parses a PARTY_ADD_MEMBER cutscene item's JSON fields into item: + * 'memberIndex', a required integer roster index. + * + * @param itemObj The item's JSON object. + * @param item Destination item, already zeroed by the caller. + * @return Error code indicating success or failure of the parse. + */ +errorret_t cutscenePartyAddMemberLoad( + yyjson_val *itemObj, + cutsceneitem_t *item +); diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyhealall.c b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealall.c new file mode 100644 index 00000000..3b3aa111 --- /dev/null +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealall.c @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "rpg/cutscene/item/cutsceneitem.h" +#include "rpg/battle/party.h" + +void cutscenePartyHealAllStart( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + partyHealAll(); +} + +bool_t cutscenePartyHealAllUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + return true; +} diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyhealall.h b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealall.h new file mode 100644 index 00000000..e2d12858 --- /dev/null +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealall.h @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "rpg/cutscene/item/cutsceneitembase.h" + +/** + * Starts a PARTY_HEAL_ALL item, fully restoring every occupied slot in + * the current save slot's party roster (see partyHealAll). + * + * @param item The cutscene item. + * @param data Runtime data storage. + */ +void cutscenePartyHealAllStart( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); + +/** + * Updates a PARTY_HEAL_ALL item (always completes immediately). + * + * @param item The cutscene item. + * @param data Runtime data storage. + * @returns true always. + */ +bool_t cutscenePartyHealAllUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.c b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.c new file mode 100644 index 00000000..5ec84707 --- /dev/null +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.c @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "rpg/cutscene/item/cutsceneitem.h" +#include "rpg/battle/party.h" +#include "rpg/cutscene/item/json/cutscenejsonfield.h" + +void cutscenePartyHealMemberStart( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + partyHealMember(item->partyHealMember.memberIndex); +} + +bool_t cutscenePartyHealMemberUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + return true; +} + +errorret_t cutscenePartyHealMemberLoad( + yyjson_val *itemObj, + cutsceneitem_t *item +) { + const int32_t memberIndex = cutsceneJsonGetInt(itemObj, "memberIndex", -1); + if(memberIndex < 0 || memberIndex >= PARTY_MEMBER_COUNT_MAX) { + errorThrow( + "Cutscene item (PARTY_HEAL_MEMBER): invalid/missing 'memberIndex'" + ); + } + item->partyHealMember.memberIndex = (uint8_t)memberIndex; + + errorOk(); +} diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.h b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.h new file mode 100644 index 00000000..e9b386d1 --- /dev/null +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyhealmember.h @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "rpg/cutscene/item/cutsceneitembase.h" + +typedef struct { + uint8_t memberIndex; +} cutscenepartyhealmember_t; + +/** + * Starts a PARTY_HEAL_MEMBER item, fully restoring the configured member's + * health/mp and reviving it if dead (see partyHealMember). + * + * @param item The cutscene item. + * @param data Runtime data storage. + */ +void cutscenePartyHealMemberStart( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); + +/** + * Updates a PARTY_HEAL_MEMBER item (always completes immediately). + * + * @param item The cutscene item. + * @param data Runtime data storage. + * @returns true always. + */ +bool_t cutscenePartyHealMemberUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); + +/** + * Parses a PARTY_HEAL_MEMBER cutscene item's JSON fields into item: + * 'memberIndex', a required integer roster index. + * + * @param itemObj The item's JSON object. + * @param item Destination item, already zeroed by the caller. + * @return Error code indicating success or failure of the parse. + */ +errorret_t cutscenePartyHealMemberLoad( + yyjson_val *itemObj, + cutsceneitem_t *item +); diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.c b/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.c new file mode 100644 index 00000000..4e444b20 --- /dev/null +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.c @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "rpg/cutscene/item/cutsceneitem.h" +#include "rpg/battle/party.h" +#include "rpg/cutscene/item/json/cutscenejsonfield.h" + +void cutscenePartyRemoveMemberStart( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + partyRemoveMember(item->partyRemoveMember.memberIndex); +} + +bool_t cutscenePartyRemoveMemberUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + return true; +} + +errorret_t cutscenePartyRemoveMemberLoad( + yyjson_val *itemObj, + cutsceneitem_t *item +) { + const int32_t memberIndex = cutsceneJsonGetInt(itemObj, "memberIndex", -1); + if(memberIndex < 0 || memberIndex >= PARTY_MEMBER_COUNT_MAX) { + errorThrow( + "Cutscene item (PARTY_REMOVE_MEMBER): invalid/missing 'memberIndex'" + ); + } + item->partyRemoveMember.memberIndex = (uint8_t)memberIndex; + + errorOk(); +} diff --git a/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.h b/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.h new file mode 100644 index 00000000..4ebeb38d --- /dev/null +++ b/src/dusk/rpg/cutscene/item/party/cutscenepartyremovemember.h @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "rpg/cutscene/item/cutsceneitembase.h" + +typedef struct { + uint8_t memberIndex; +} cutscenepartyremovemember_t; + +/** + * Starts a PARTY_REMOVE_MEMBER item, deactivating the configured member + * from the current save slot's active party order (see partyRemoveMember) + * - the roster member itself still exists, just no longer active. + * + * @param item The cutscene item. + * @param data Runtime data storage. + */ +void cutscenePartyRemoveMemberStart( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); + +/** + * Updates a PARTY_REMOVE_MEMBER item (always completes immediately). + * + * @param item The cutscene item. + * @param data Runtime data storage. + * @returns true always. + */ +bool_t cutscenePartyRemoveMemberUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); + +/** + * Parses a PARTY_REMOVE_MEMBER cutscene item's JSON fields into item: + * 'memberIndex', a required integer roster index. + * + * @param itemObj The item's JSON object. + * @param item Destination item, already zeroed by the caller. + * @return Error code indicating success or failure of the parse. + */ +errorret_t cutscenePartyRemoveMemberLoad( + yyjson_val *itemObj, + cutsceneitem_t *item +); diff --git a/src/dusk/rpg/rpg.c b/src/dusk/rpg/rpg.c index 56115801..291f35cf 100644 --- a/src/dusk/rpg/rpg.c +++ b/src/dusk/rpg/rpg.c @@ -33,7 +33,6 @@ errorret_t rpgInit(void) { errorChain(itemInit()); errorChain(battleMoveInit()); backpackInit(); - partyInit(); cutsceneSystemInit(); errorChain(mapInit()); rpgCameraInit(); diff --git a/src/dusk/save/save.c b/src/dusk/save/save.c index afdb958d..9c8ece44 100644 --- a/src/dusk/save/save.c +++ b/src/dusk/save/save.c @@ -21,7 +21,7 @@ errorret_t saveInit() { // Initialize the slots and settings saveSettingsInit(&SAVE.settings); - saveSlotInit(); + errorChain(saveSlotInit(&SAVE.slot)); // Update caches to match default data. for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) { @@ -226,7 +226,7 @@ errorret_t saveLoadSlot() { if(SAVE.deviceCurrent == 0xFF) { // No save device available, so we assume an empty slot. - saveSlotInit(); + errorChain(saveSlotInit(&SAVE.slot)); SAVE.caches[SAVE.slotCurrent] = SAVE.slot.cachedData; errorOk(); } @@ -245,7 +245,7 @@ errorret_t saveLoadSlot() { "Save slot %u could not be loaded, treating as empty.\n", (uint32_t)SAVE.slotCurrent ); - saveSlotInit(); + errorChain(saveSlotInit(&SAVE.slot)); SAVE.slot.cachedData.corrupt = true; } @@ -260,7 +260,7 @@ errorret_t saveLoadSlot() { errorret_t saveLoadAllSlots() { for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) { SAVE.slotCurrent = i; - saveSlotInit(); + errorChain(saveSlotInit(&SAVE.slot)); errorChain(saveLoadSlot());// Load slot updates the cache. } diff --git a/src/dusk/save/slot/saveslot.c b/src/dusk/save/slot/saveslot.c index f739e3d1..a4eeff45 100644 --- a/src/dusk/save/slot/saveslot.c +++ b/src/dusk/save/slot/saveslot.c @@ -12,33 +12,33 @@ #include "util/memory.h" #include "util/string.h" -void saveSlotInit(void) { - memorySet(&SAVE.slot, 0, sizeof(saveslot_t)); +errorret_t saveSlotInit(saveslot_t *slot) { + assertNotNull(slot, "Slot cannot be null"); - SAVE.slot.version = 1; + // Zero the save slot. + memoryZero(slot, sizeof(saveslot_t)); + slot->version = 1; + // Set the default story flags. for(storyflag_t flag = 0; flag < STORY_FLAG_COUNT; flag++) { - SAVE.slot.storyFlags[flag] = STORY_FLAG_MAP[flag].initial; + slot->storyFlags[flag] = STORY_FLAG_MAP[flag].initial; } - partyInit(); + // Set the default party. + errorChain(partyInit(&slot->party)); + + errorOk(); } -void saveSlotNewGame(void) { - saveSlotInit(); +errorret_t saveSlotNewGame(void) { + errorChain(saveSlotInit(&SAVE.slot)); stringCopy( SAVE.slot.cachedData.mapName, SAVE_SLOT_MAP_NAME_DEFAULT, sizeof(SAVE.slot.cachedData.mapName) ); - // TEMPORARY: placeholder starting party stats - replace once there's a - // real starting-party/character-creation flow. - const battlefighterstats_t startingStats = { - .attack = 10, .defense = 5, .magic = 0, .speed = 10, .luck = 0, - .healthMax = 30, .mpMax = 10 - }; - partyAddMember(startingStats); + errorOk(); } bool_t saveSlotInUse(saveslotcache_t *slot) { @@ -64,14 +64,13 @@ errorret_t saveSlotReadJSON(saveslot_t *slot, yyjson_val *object) { assertNotNull(slot, "Slot cannot be null"); assertNotNull(object, "Object cannot be null"); - // A plain reset, not saveSlotInit() - this may be reading into a slot - // other than SAVE.slot (e.g. a save-device round trip in a test), and - // saveSlotInit() only ever operates on SAVE.slot now. saveSlotCurrent - // ReadJSON() fully overwrites every field below anyway (including - // party.members/order), so this only needs to reset the bookkeeping - // fields (version/dataType) that aren't part of the JSON wire format. - memorySet(slot, 0, sizeof(saveslot_t)); - slot->version = 1; + // saveSlotInit() now takes slot directly, so this can reset through it + // rather than duplicating the memSet/version stub - saveSlotCurrent + // ReadJSON() fully overwrites every field it sets anyway (including + // party.members/order), so seeding a fresh party.jsonc roster here only + // to replace it is wasted work, but keeps this in one reset path rather + // than two subtly different ones. + errorChain(saveSlotInit(slot)); errorChain(saveSlotCurrentReadJSON(slot, object)); errorOk(); diff --git a/src/dusk/save/slot/saveslot.h b/src/dusk/save/slot/saveslot.h index b4fa58ff..7ce56691 100644 --- a/src/dusk/save/slot/saveslot.h +++ b/src/dusk/save/slot/saveslot.h @@ -37,22 +37,27 @@ typedef struct saveslot_s { } saveslot_t; /** - * Inits the current save slot (SAVE.slot) with the default state, this is - * functionally "new game" but will not set the player name, as that is - * what we use to determine if a save slot is "in use" or not. + * Inits slot with the default state, this is functionally "new game" but + * will not set the player name, as that is what we use to determine if a + * save slot is "in use" or not. Seeds the party roster from party.jsonc + * (see partyInit). + * + * @param slot The save slot to initialize. + * @return Error if the starting party roster (party.jsonc) fails to + * load/parse. */ -void saveSlotInit(void); +errorret_t saveSlotInit(saveslot_t *slot); /** * Sets up the current save slot (SAVE.slot) for a brand new game: calls - * saveSlotInit, then seeds it with a starting party. Use this (not - * saveSlotInit) whenever a new save is actually being created for a - * player - saveSlotInit alone leaves the party empty, which every other - * caller (resetting the slot before a load attempt, wiping a deleted - * slot) wants. Callers creating a new save at a specific index should set - * SAVE.slotCurrent first. + * saveSlotInit (which seeds the starting party roster) and sets the + * default starting map. Use this (not saveSlotInit directly) whenever a + * new save is actually being created for a player. Callers creating a + * new save at a specific index should set SAVE.slotCurrent first. + * + * @return Error if saveSlotInit fails. */ -void saveSlotNewGame(void); +errorret_t saveSlotNewGame(void); /** * Checks if the save slot is in use, this is determined by checking if the diff --git a/src/dusk/ui/dialog/save/uiselectsave.c b/src/dusk/ui/dialog/save/uiselectsave.c index d012c689..dcdf4c2c 100644 --- a/src/dusk/ui/dialog/save/uiselectsave.c +++ b/src/dusk/ui/dialog/save/uiselectsave.c @@ -107,7 +107,12 @@ void uiSelectSaveDeleteConfirmed(const bool_t result, void *user) { // uimainmenu.c), so it's safe to reuse SAVE.slot as scratch for whichever // index is being written here - nothing else is relying on it. SAVE.slotCurrent = index; - saveSlotInit(); + errorret_t initResult = saveSlotInit(&SAVE.slot); + if(errorIsNotOk(initResult)) { + errorCatch(errorPrint(initResult)); + uiFatalErrorOpen(initResult.state->message); + return; + } errorret_t writeResult = saveDeviceSlotWrite( &SAVE.devices[SAVE.deviceCurrent], &SAVE.slot, index @@ -144,7 +149,12 @@ void uiSelectSaveNameEntered( // uimainmenu.c), so it's safe to reuse SAVE.slot as scratch for whichever // index is being written here - nothing else is relying on it. SAVE.slotCurrent = index; - saveSlotNewGame(); + errorret_t newGameResult = saveSlotNewGame(); + if(errorIsNotOk(newGameResult)) { + errorCatch(errorPrint(newGameResult)); + uiFatalErrorOpen(newGameResult.state->message); + return; + } stringCopy(SAVE.slot.cachedData.name, text, SAVE_SLOT_NAME_LENGTH); // No save device available - proceed with an in-memory-only slot rather diff --git a/src/dusk/ui/screen/battle/uibattlemenu.c b/src/dusk/ui/screen/battle/uibattlemenu.c index 2b88343c..1b18942c 100644 --- a/src/dusk/ui/screen/battle/uibattlemenu.c +++ b/src/dusk/ui/screen/battle/uibattlemenu.c @@ -62,6 +62,11 @@ void uiBattleMenuOpenTargets(void) { uiMenuOpen(&UI_BATTLE_MENU.targetMenu); } +bool_t uiBattleMenuActionCancel(const uimenu_t *menu) { + battleStateSelectionGoBack(); + return true; +} + void uiBattleMenuActionSelected( const uimenu_t *menu, const uint8_t index, @@ -112,6 +117,8 @@ errorret_t uiBattleMenuInit(void) { MENU_BUTTON(UI_BATTLE_MENU.fleeLabel); MENU_END(UI_BATTLE_MENU.actionItems, 1); + uiMenuSetCancelCallback(&UI_BATTLE_MENU.actionMenu, uiBattleMenuActionCancel); + errorOk(); } diff --git a/src/dusk/ui/screen/mainmenu/uimainmenu.c b/src/dusk/ui/screen/mainmenu/uimainmenu.c index 9632cf8a..8ab856b4 100644 --- a/src/dusk/ui/screen/mainmenu/uimainmenu.c +++ b/src/dusk/ui/screen/mainmenu/uimainmenu.c @@ -38,7 +38,13 @@ void uiMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) { } SAVE.slotCurrent = slotIndex; - saveSlotInit(); + errorret_t initResult = saveSlotInit(&SAVE.slot); + if(errorIsNotOk(initResult)) { + errorCatch(errorPrint(initResult)); + uiFatalErrorOpen(initResult.state->message); + return; + } + errorret_t result = saveLoadSlot(); if(errorIsNotOk(result)) { errorCatch(errorPrint(result)); diff --git a/test/save/CMakeLists.txt b/test/save/CMakeLists.txt index 52c3a9b0..d4164abb 100644 --- a/test/save/CMakeLists.txt +++ b/test/save/CMakeLists.txt @@ -8,6 +8,7 @@ include(dusktest) # Tests dusktest(test_savesettings.c) dusktest(test_saveslot.c) +target_sources(test_saveslot PRIVATE savetestfixture.c) dusktest(test_savedevicelinux.c) target_sources(test_savedevicelinux PRIVATE savetestfixture.c) diff --git a/test/save/savetestfixture.c b/test/save/savetestfixture.c index 5db6bb3b..ff1df739 100644 --- a/test/save/savetestfixture.c +++ b/test/save/savetestfixture.c @@ -10,6 +10,9 @@ #include "savetestfixture.h" #include "assert/assert.h" +#include "asset/asset.h" +#include "asset/loader/assetloader.h" +#include "asset/loader/assetentry.h" #include "util/memory.h" #include "util/string.h" #include @@ -22,6 +25,51 @@ static char_t SAVE_TEST_FIXTURE_HOME[PATH_MAX]; static char_t SAVE_TEST_FIXTURE_REAL_HOME[PATH_MAX]; static bool_t SAVE_TEST_FIXTURE_HAD_REAL_HOME; +static const char_t *SAVE_TEST_FIXTURE_PARTY_JSON = + "{\"members\":[{" + "\"attack\":10,\"defense\":5,\"magic\":0,\"speed\":10,\"luck\":0," + "\"healthMax\":30,\"mpMax\":10" + "}]}"; + +static yyjson_doc *SAVE_TEST_FIXTURE_PARTY_DOC = NULL; + +errorret_t saveTestFixturePartyJsonLoadSyncStub(assetloading_t *loading) { + loading->entry->data.json = SAVE_TEST_FIXTURE_PARTY_DOC; + loading->entry->state = ASSET_ENTRY_STATE_LOADED; + errorOk(); +} + +errorret_t saveTestFixturePartyJsonDisposeStub(assetentry_t *entry) { + errorOk(); +} + +void saveTestFixtureStubPartyAsset(void) { + memoryZero(&ASSET, sizeof(ASSET)); + for(size_t i = 0; i < ASSET_LOADING_COUNT_MAX; i++) { + threadMutexInit(&ASSET.loading[i].mutex); + } + + if(SAVE_TEST_FIXTURE_PARTY_DOC == NULL) { + SAVE_TEST_FIXTURE_PARTY_DOC = yyjson_read( + SAVE_TEST_FIXTURE_PARTY_JSON, strlen(SAVE_TEST_FIXTURE_PARTY_JSON), 0 + ); + assertNotNull( + SAVE_TEST_FIXTURE_PARTY_DOC, "Failed to parse party JSON fixture" + ); + } + + ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_JSON].loadSync = + saveTestFixturePartyJsonLoadSyncStub; + ASSET_LOADER_CALLBACKS[ASSET_LOADER_TYPE_JSON].dispose = + saveTestFixturePartyJsonDisposeStub; +} + +void saveTestFixtureUnstubPartyAsset(void) { + if(SAVE_TEST_FIXTURE_PARTY_DOC == NULL) return; + yyjson_doc_free(SAVE_TEST_FIXTURE_PARTY_DOC); + SAVE_TEST_FIXTURE_PARTY_DOC = NULL; +} + static int_t saveTestFixtureRemoveEntry( const char *path, const struct stat *statBuffer, @@ -49,6 +97,8 @@ int saveTestFixtureSetup(void **state) { assertTrue(setenv("HOME", SAVE_TEST_FIXTURE_HOME, 1) == 0, "setenv failed"); + saveTestFixtureStubPartyAsset(); + errorret_t ret = saveInit(); assertTrue(errorIsOk(ret), "saveInit failed in test fixture"); @@ -56,6 +106,8 @@ int saveTestFixtureSetup(void **state) { } int saveTestFixtureTeardown(void **state) { + saveTestFixtureUnstubPartyAsset(); + nftw( SAVE_TEST_FIXTURE_HOME, saveTestFixtureRemoveEntry, 16, FTW_DEPTH | FTW_PHYS diff --git a/test/save/savetestfixture.h b/test/save/savetestfixture.h index e4ed7c03..2ac2bde8 100644 --- a/test/save/savetestfixture.h +++ b/test/save/savetestfixture.h @@ -35,3 +35,21 @@ int saveTestFixtureTeardown(void **state); * filesystem permission tricks. Must be called after saveTestFixtureSetup. */ void saveTestFixtureBlockSaveDirectory(void); + +/** + * Stubs the JSON asset loader's sync callback to hand back a small + * in-memory party.jsonc-shaped doc instead of doing a real file/zip read - + * saveInit()/saveSlotInit() seed the party roster via partyInit(), which + * loads party.jsonc through the real asset system, and there's no real + * asset bundle available under the test harness. No thread, no real I/O. + * Resets ASSET to a fresh, otherwise-empty state each call, so it's safe + * to call once per test. Called automatically by saveTestFixtureSetup. + */ +void saveTestFixtureStubPartyAsset(void); + +/** + * Frees the in-memory doc created by saveTestFixtureStubPartyAsset. Safe + * to call even if that function was never called. Called automatically + * by saveTestFixtureTeardown. + */ +void saveTestFixtureUnstubPartyAsset(void); diff --git a/test/save/test_save.c b/test/save/test_save.c index 4628cd5c..a04946b8 100644 --- a/test/save/test_save.c +++ b/test/save/test_save.c @@ -298,7 +298,7 @@ static void test_saveSaveSlot_invalidSlotIndexAsserts(void **state) { static void test_saveSaveSlot_success(void **state) { makeDeviceAvailable(); SAVE.slotCurrent = 1; - saveSlotInit(); + saveSlotInit(&SAVE.slot); stringCopy( SAVE.slot.cachedData.name, "Hero", sizeof(SAVE.slot.cachedData.name) ); @@ -336,7 +336,7 @@ static void test_saveLoadSlot_success(void **state) { // ever operates on SAVE.slot now), then reset it again below - the // write and the load-under-test don't overlap in time. SAVE.slotCurrent = 2; - saveSlotInit(); + saveSlotInit(&SAVE.slot); stringCopy( SAVE.slot.cachedData.name, "Zelda", sizeof(SAVE.slot.cachedData.name) ); @@ -345,7 +345,7 @@ static void test_saveLoadSlot_success(void **state) { assert_true(errorIsOk(writeRet)); SAVE.slotCurrent = 2; - saveSlotInit(); + saveSlotInit(&SAVE.slot); SAVE.slotDirty = true; errorret_t ret = saveLoadSlot(); @@ -371,7 +371,7 @@ static void test_saveLoadSlot_deviceReadFails_cacheStaysStale(void **state) { fclose(file); SAVE.slotCurrent = 0; - saveSlotInit(); + saveSlotInit(&SAVE.slot); SAVE.caches[0].playerLevel = 77;// sentinel errorret_t ret = saveLoadSlot(); @@ -391,7 +391,7 @@ static void test_saveLoadAllSlots_loadsAllAndEndsAtLastIndex(void **state) { for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) { SAVE.slotCurrent = i; - saveSlotInit(); + saveSlotInit(&SAVE.slot); SAVE.slot.cachedData.playerLevel = (int32_t)(i + 1); errorret_t saveRet = saveSaveSlot(); assert_true(errorIsOk(saveRet)); @@ -410,7 +410,7 @@ static void test_saveLoadAllSlots_missingFileLeavesInitDefaults(void **state) { makeDeviceAvailable(); SAVE.slotCurrent = 0; - saveSlotInit(); + saveSlotInit(&SAVE.slot); SAVE.slot.cachedData.playerLevel = 5; errorret_t saveRet = saveSaveSlot(); assert_true(errorIsOk(saveRet)); @@ -433,7 +433,7 @@ static void test_saveLoadAllSlots_middleSlotCorrupt_stopsAtFailingIndex( makeDeviceAvailable(); SAVE.slotCurrent = 0; - saveSlotInit(); + saveSlotInit(&SAVE.slot); errorret_t saveRet = saveSaveSlot(); assert_true(errorIsOk(saveRet)); diff --git a/test/save/test_savedevice.c b/test/save/test_savedevice.c index 02bc800e..7f4bf449 100644 --- a/test/save/test_savedevice.c +++ b/test/save/test_savedevice.c @@ -201,7 +201,7 @@ static void test_saveDeviceSlot_dispatchesToPlatform(void **state) { // saveSlotInit() only ever operates on SAVE.slot now, so that's the // write source here. - saveSlotInit(); + saveSlotInit(&SAVE.slot); SAVE.slot.cachedData.playerLevel = 21; errorret_t writeRet = saveDeviceSlotWrite(&device, &SAVE.slot, 0); diff --git a/test/save/test_savedevicelinux.c b/test/save/test_savedevicelinux.c index f7c3f3f0..a9ba2ea2 100644 --- a/test/save/test_savedevicelinux.c +++ b/test/save/test_savedevicelinux.c @@ -206,7 +206,7 @@ static void test_slotWriteRead_roundTrip(void **state) { // saveSlotInit() only ever operates on SAVE.slot now, so that's the // write source here. - saveSlotInit(); + saveSlotInit(&SAVE.slot); stringCopy( SAVE.slot.cachedData.name, "Hero", sizeof(SAVE.slot.cachedData.name) ); diff --git a/test/save/test_saveslot.c b/test/save/test_saveslot.c index 8309cae3..fab7b8a0 100644 --- a/test/save/test_saveslot.c +++ b/test/save/test_saveslot.c @@ -9,11 +9,25 @@ #include "save/slot/saveslot.h" #include "save/save.h" #include "save/savejson.h" +#include "savetestfixture.h" #include "util/memory.h" #include "util/string.h" #include #include +// saveSlotInit() seeds the party roster via partyInit(), which loads +// party.jsonc through the real asset system - stub it out (see +// savetestfixture.h) rather than needing a real asset bundle here. +static int assetGroupSetup(void **state) { + saveTestFixtureStubPartyAsset(); + return 0; +} + +static int assetGroupTeardown(void **state) { + saveTestFixtureUnstubPartyAsset(); + return 0; +} + // ============================================================ // Helpers - wrap the writeInit/readInit macros (which can only be used // inside a function returning errorret_t) so tests can call them plainly. @@ -52,7 +66,7 @@ static errorret_t slotFromJSON(saveslot_t *slot, const char_t *json) { static void test_saveSlotInit_defaults(void **state) { memorySet(&SAVE.slot, 0xFF, sizeof(SAVE.slot)); - saveSlotInit(); + saveSlotInit(&SAVE.slot); assert_int_equal(SAVE.slot.version, 1); assert_int_equal(SAVE.slot.dataType, 0); @@ -66,7 +80,7 @@ static void test_saveSlotInit_defaults(void **state) { // ============================================================ static void test_saveSlotInUse(void **state) { - saveSlotInit(); + saveSlotInit(&SAVE.slot); assert_false(saveSlotInUse(&SAVE.slot.cachedData)); @@ -81,7 +95,7 @@ static void test_saveSlotInUse_nullAsserts(void **state) { } static void test_saveSlotHasSaved(void **state) { - saveSlotInit(); + saveSlotInit(&SAVE.slot); assert_false(saveSlotHasSaved(&SAVE.slot.cachedData)); @@ -136,7 +150,7 @@ static void test_saveSlotReadJSON_nullAsserts(void **state) { static void test_saveSlotReadJSON_roundTrip(void **state) { // saveSlotInit() only ever operates on SAVE.slot now, so that's the // write source here. - saveSlotInit(); + saveSlotInit(&SAVE.slot); SAVE.slot.version = 7;// deliberately non-default, not part of the JSON schema stringCopy( SAVE.slot.cachedData.name, "Hero", sizeof(SAVE.slot.cachedData.name) @@ -296,5 +310,5 @@ int main(void) { cmocka_unit_test(test_saveSlotReadJSON_versionMismatch_errors), }; - return cmocka_run_group_tests(tests, NULL, NULL); + return cmocka_run_group_tests(tests, assetGroupSetup, assetGroupTeardown); }