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 <[email protected]>
This commit is contained in:
2026-09-21 22:27:46 -05:00
co-authored by Claude Sonnet 5
parent e8757c33e3
commit 7a9a2b9832
35 changed files with 838 additions and 99 deletions
+1
View File
@@ -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)
+52
View File
@@ -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 <ftw.h>
@@ -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
+18
View File
@@ -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);
+7 -7
View File
@@ -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));
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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)
);
+19 -5
View File
@@ -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 <stdlib.h>
#include <string.h>
// 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);
}