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
+13
View File
@@ -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
}
]
}
+150 -26
View File
@@ -9,49 +9,134 @@
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
#include "save/save.h" #include "save/save.h"
#include "asset/asset.h"
#include "asset/loader/assetloader.h"
void partyInit(void) { errorret_t partyParseStatField(
party_t *party = &SAVE.slot.party; 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)); 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++) { for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
party->order[i] = PARTY_ORDER_EMPTY; party->order[i] = PARTY_ORDER_EMPTY;
} }
}
uint8_t partyGetAvailableMember(void) { assetentry_t *entry = assetLock("party.jsonc", ASSET_LOADER_TYPE_JSON, NULL);
const party_t *party = &SAVE.slot.party; errorret_t result = assetRequireLoaded(entry);
if(errorIsNotOk(result)) {
for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) { assetUnlockEntry(entry);
if(party->members[i].status == BATTLE_FIGHTER_STATUS_NULL) return i; 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( bool_t partyIsMemberActive(const uint8_t memberIndex) {
const battlefighterstats_t stats 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; party_t *party = &SAVE.slot.party;
assertTrue(
const uint8_t index = partyGetAvailableMember(); party->members[memberIndex].status != BATTLE_FIGHTER_STATUS_NULL,
if(index == 0xFF) return NULL; "Member does not exist in the roster"
battlefighter_t *member = &party->members[index];
battleFighterInit(
member, BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, stats
); );
if(partyIsMemberActive(memberIndex)) return;
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) { for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
if(party->order[i] != PARTY_ORDER_EMPTY) continue; if(party->order[i] != PARTY_ORDER_EMPTY) continue;
party->order[i] = index; party->order[i] = memberIndex;
break; return;
} }
return member;
} }
battlefighter_t *partyGetOrderMember(const uint8_t slot) { 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; 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);
}
}
+69 -17
View File
@@ -7,44 +7,69 @@
#pragma once #pragma once
#include "rpg/battle/fighter/battlefighter.h" #include "rpg/battle/fighter/battlefighter.h"
#include "yyjson.h"
#define PARTY_MEMBER_COUNT_MAX 4 #define PARTY_MEMBER_COUNT_MAX 4
#define PARTY_ACTIVE_SIZE_MAX 3 #define PARTY_ACTIVE_SIZE_MAX 3
#define PARTY_ORDER_EMPTY 0xFF #define PARTY_ORDER_EMPTY 0xFF
typedef struct { 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]; battlefighter_t members[PARTY_MEMBER_COUNT_MAX];
uint8_t order[PARTY_ACTIVE_SIZE_MAX]; uint8_t order[PARTY_ACTIVE_SIZE_MAX];
// TODO: Whether party members are "front" or "back". // TODO: Whether party members are "front" or "back".
} party_t; } party_t;
/** /**
* Initializes the current save slot's party (SAVE.slot.party) with an * Initializes party with an empty order, then seeds every roster slot
* empty roster and order. * 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 * Parses a set of base combat stats (attack/defense/magic/speed/luck/
* party. * 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 * Checks whether memberIndex currently occupies a slot in the current save
* available slot. Party members are always allies controlled by the * slot's party order (i.e. is actively in the party right now).
* player. If there is a free active order slot, the new member is placed
* into it.
* *
* @param stats The member's base combat stats, including healthMax/mpMax. * @param memberIndex The roster member index to check.
* @return Pointer to the newly added party member, or NULL if the party is * @return True if memberIndex appears anywhere in order.
* already full.
*/ */
battlefighter_t *partyAddMember( bool_t partyIsMemberActive(const uint8_t memberIndex);
const battlefighterstats_t stats
); /**
* 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 * 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. * PARTY_ORDER_EMPTY to clear the slot.
*/ */
void partySetOrder(const uint8_t slot, const uint8_t memberIndex); 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);
@@ -115,6 +115,28 @@ void battleStateSelectionPlayerAttack(const uint8_t targetIndex) {
BATTLE.stateData.selection.selectionIndex++; 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) { void battleStateSelectionPlayerFlee(void) {
battlefighter_t *fighter = battleStateSelectionGetCurrentFighter(); battlefighter_t *fighter = battleStateSelectionGetCurrentFighter();
if(fighter == NULL) return; if(fighter == NULL) return;
@@ -109,3 +109,12 @@ void battleStateSelectionPlayerAttack(const uint8_t targetIndex);
* BATTLE_STATE_SELECTION and awaiting a decision. * BATTLE_STATE_SELECTION and awaiting a decision.
*/ */
void battleStateSelectionPlayerFlee(void); 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);
+11
View File
@@ -125,6 +125,17 @@ void cutsceneSystemInsertCutscene(const cutscene_t *cutscene) {
cutsceneSystemInsertItems(cutscene->items, cutscene->itemCount); 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) { void cutsceneSystemLoad(const char_t *file) {
assertNotNull(file, "File cannot be NULL"); assertNotNull(file, "File cannot be NULL");
+11
View File
@@ -156,6 +156,17 @@ void cutsceneSystemInsertItems(
*/ */
void cutsceneSystemInsertCutscene(const cutscene_t *cutscene); 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. * Loads and immediately starts a cutscene asset by file name.
* *
@@ -21,3 +21,4 @@ add_subdirectory(save)
add_subdirectory(audio) add_subdirectory(audio)
add_subdirectory(json) add_subdirectory(json)
add_subdirectory(story) add_subdirectory(story)
add_subdirectory(party)
@@ -35,6 +35,12 @@ void cutsceneBattlePostMoveStart(
BATTLE.result = allyAlive ? BATTLE_RESULT_WIN : BATTLE_RESULT_LOSS; 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[] = { const cutsceneitem_t items[] = {
{ {
.type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE, .type = CUTSCENE_ITEM_TYPE_BATTLE_SET_STATE,
+29
View File
@@ -371,6 +371,35 @@ cutsceneitemcallbacks_t CUTSCENE_ITEM_CALLBACKS[CUTSCENE_ITEM_TYPE_COUNT] = {
.update = cutsceneStoryWaitFlagUpdate, .update = cutsceneStoryWaitFlagUpdate,
.jsonName = "STORY_WAIT_FLAG", .jsonName = "STORY_WAIT_FLAG",
.load = cutsceneStoryWaitFlagLoad .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
} }
}; };
@@ -59,6 +59,10 @@
#include "audio/cutsceneaudioset.h" #include "audio/cutsceneaudioset.h"
#include "story/cutscenestorysetflag.h" #include "story/cutscenestorysetflag.h"
#include "story/cutscenestorywaitflag.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; typedef struct cutscene_s cutscene_t;
@@ -144,6 +148,9 @@ struct cutsceneitem_s {
cutsceneuishow_t uiShow; cutsceneuishow_t uiShow;
cutscenestorysetflag_t storySetFlag; cutscenestorysetflag_t storySetFlag;
cutscenestorywaitflag_t storyWaitFlag; cutscenestorywaitflag_t storyWaitFlag;
cutscenepartyaddmember_t partyAddMember;
cutscenepartyremovemember_t partyRemoveMember;
cutscenepartyhealmember_t partyHealMember;
}; };
}; };
@@ -86,6 +86,12 @@ typedef enum {
CUTSCENE_ITEM_TYPE_STORY_SET_FLAG, CUTSCENE_ITEM_TYPE_STORY_SET_FLAG,
CUTSCENE_ITEM_TYPE_STORY_WAIT_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 CUTSCENE_ITEM_TYPE_COUNT
} cutsceneitemtype_t; } cutsceneitemtype_t;
@@ -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
)
@@ -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();
}
@@ -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
);
@@ -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;
}
@@ -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
);
@@ -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();
}
@@ -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
);
@@ -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();
}
@@ -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
);
-1
View File
@@ -33,7 +33,6 @@ errorret_t rpgInit(void) {
errorChain(itemInit()); errorChain(itemInit());
errorChain(battleMoveInit()); errorChain(battleMoveInit());
backpackInit(); backpackInit();
partyInit();
cutsceneSystemInit(); cutsceneSystemInit();
errorChain(mapInit()); errorChain(mapInit());
rpgCameraInit(); rpgCameraInit();
+4 -4
View File
@@ -21,7 +21,7 @@ errorret_t saveInit() {
// Initialize the slots and settings // Initialize the slots and settings
saveSettingsInit(&SAVE.settings); saveSettingsInit(&SAVE.settings);
saveSlotInit(); errorChain(saveSlotInit(&SAVE.slot));
// Update caches to match default data. // Update caches to match default data.
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) { for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
@@ -226,7 +226,7 @@ errorret_t saveLoadSlot() {
if(SAVE.deviceCurrent == 0xFF) { if(SAVE.deviceCurrent == 0xFF) {
// No save device available, so we assume an empty slot. // No save device available, so we assume an empty slot.
saveSlotInit(); errorChain(saveSlotInit(&SAVE.slot));
SAVE.caches[SAVE.slotCurrent] = SAVE.slot.cachedData; SAVE.caches[SAVE.slotCurrent] = SAVE.slot.cachedData;
errorOk(); errorOk();
} }
@@ -245,7 +245,7 @@ errorret_t saveLoadSlot() {
"Save slot %u could not be loaded, treating as empty.\n", "Save slot %u could not be loaded, treating as empty.\n",
(uint32_t)SAVE.slotCurrent (uint32_t)SAVE.slotCurrent
); );
saveSlotInit(); errorChain(saveSlotInit(&SAVE.slot));
SAVE.slot.cachedData.corrupt = true; SAVE.slot.cachedData.corrupt = true;
} }
@@ -260,7 +260,7 @@ errorret_t saveLoadSlot() {
errorret_t saveLoadAllSlots() { errorret_t saveLoadAllSlots() {
for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) { for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
SAVE.slotCurrent = i; SAVE.slotCurrent = i;
saveSlotInit(); errorChain(saveSlotInit(&SAVE.slot));
errorChain(saveLoadSlot());// Load slot updates the cache. errorChain(saveLoadSlot());// Load slot updates the cache.
} }
+21 -22
View File
@@ -12,33 +12,33 @@
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h" #include "util/string.h"
void saveSlotInit(void) { errorret_t saveSlotInit(saveslot_t *slot) {
memorySet(&SAVE.slot, 0, sizeof(saveslot_t)); 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++) { 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) { errorret_t saveSlotNewGame(void) {
saveSlotInit(); errorChain(saveSlotInit(&SAVE.slot));
stringCopy( stringCopy(
SAVE.slot.cachedData.mapName, SAVE_SLOT_MAP_NAME_DEFAULT, SAVE.slot.cachedData.mapName, SAVE_SLOT_MAP_NAME_DEFAULT,
sizeof(SAVE.slot.cachedData.mapName) sizeof(SAVE.slot.cachedData.mapName)
); );
// TEMPORARY: placeholder starting party stats - replace once there's a errorOk();
// 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);
} }
bool_t saveSlotInUse(saveslotcache_t *slot) { 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(slot, "Slot cannot be null");
assertNotNull(object, "Object cannot be null"); assertNotNull(object, "Object cannot be null");
// A plain reset, not saveSlotInit() - this may be reading into a slot // saveSlotInit() now takes slot directly, so this can reset through it
// other than SAVE.slot (e.g. a save-device round trip in a test), and // rather than duplicating the memSet/version stub - saveSlotCurrent
// saveSlotInit() only ever operates on SAVE.slot now. saveSlotCurrent // ReadJSON() fully overwrites every field it sets anyway (including
// ReadJSON() fully overwrites every field below anyway (including // party.members/order), so seeding a fresh party.jsonc roster here only
// party.members/order), so this only needs to reset the bookkeeping // to replace it is wasted work, but keeps this in one reset path rather
// fields (version/dataType) that aren't part of the JSON wire format. // than two subtly different ones.
memorySet(slot, 0, sizeof(saveslot_t)); errorChain(saveSlotInit(slot));
slot->version = 1;
errorChain(saveSlotCurrentReadJSON(slot, object)); errorChain(saveSlotCurrentReadJSON(slot, object));
errorOk(); errorOk();
+16 -11
View File
@@ -37,22 +37,27 @@ typedef struct saveslot_s {
} saveslot_t; } saveslot_t;
/** /**
* Inits the current save slot (SAVE.slot) with the default state, this is * Inits slot with the default state, this is functionally "new game" but
* functionally "new game" but will not set the player name, as that is * will not set the player name, as that is what we use to determine if a
* what we use to determine if a save slot is "in use" or not. * 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 * 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 (which seeds the starting party roster) and sets the
* saveSlotInit) whenever a new save is actually being created for a * default starting map. Use this (not saveSlotInit directly) whenever a
* player - saveSlotInit alone leaves the party empty, which every other * new save is actually being created for a player. Callers creating a
* caller (resetting the slot before a load attempt, wiping a deleted * new save at a specific index should set SAVE.slotCurrent first.
* slot) wants. 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 * Checks if the save slot is in use, this is determined by checking if the
+12 -2
View File
@@ -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 // 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. // index is being written here - nothing else is relying on it.
SAVE.slotCurrent = index; 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( errorret_t writeResult = saveDeviceSlotWrite(
&SAVE.devices[SAVE.deviceCurrent], &SAVE.slot, index &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 // 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. // index is being written here - nothing else is relying on it.
SAVE.slotCurrent = index; 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); stringCopy(SAVE.slot.cachedData.name, text, SAVE_SLOT_NAME_LENGTH);
// No save device available - proceed with an in-memory-only slot rather // No save device available - proceed with an in-memory-only slot rather
+7
View File
@@ -62,6 +62,11 @@ void uiBattleMenuOpenTargets(void) {
uiMenuOpen(&UI_BATTLE_MENU.targetMenu); uiMenuOpen(&UI_BATTLE_MENU.targetMenu);
} }
bool_t uiBattleMenuActionCancel(const uimenu_t *menu) {
battleStateSelectionGoBack();
return true;
}
void uiBattleMenuActionSelected( void uiBattleMenuActionSelected(
const uimenu_t *menu, const uimenu_t *menu,
const uint8_t index, const uint8_t index,
@@ -112,6 +117,8 @@ errorret_t uiBattleMenuInit(void) {
MENU_BUTTON(UI_BATTLE_MENU.fleeLabel); MENU_BUTTON(UI_BATTLE_MENU.fleeLabel);
MENU_END(UI_BATTLE_MENU.actionItems, 1); MENU_END(UI_BATTLE_MENU.actionItems, 1);
uiMenuSetCancelCallback(&UI_BATTLE_MENU.actionMenu, uiBattleMenuActionCancel);
errorOk(); errorOk();
} }
+7 -1
View File
@@ -38,7 +38,13 @@ void uiMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) {
} }
SAVE.slotCurrent = slotIndex; 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(); errorret_t result = saveLoadSlot();
if(errorIsNotOk(result)) { if(errorIsNotOk(result)) {
errorCatch(errorPrint(result)); errorCatch(errorPrint(result));
+1
View File
@@ -8,6 +8,7 @@ include(dusktest)
# Tests # Tests
dusktest(test_savesettings.c) dusktest(test_savesettings.c)
dusktest(test_saveslot.c) dusktest(test_saveslot.c)
target_sources(test_saveslot PRIVATE savetestfixture.c)
dusktest(test_savedevicelinux.c) dusktest(test_savedevicelinux.c)
target_sources(test_savedevicelinux PRIVATE savetestfixture.c) target_sources(test_savedevicelinux PRIVATE savetestfixture.c)
+52
View File
@@ -10,6 +10,9 @@
#include "savetestfixture.h" #include "savetestfixture.h"
#include "assert/assert.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/memory.h"
#include "util/string.h" #include "util/string.h"
#include <ftw.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 char_t SAVE_TEST_FIXTURE_REAL_HOME[PATH_MAX];
static bool_t SAVE_TEST_FIXTURE_HAD_REAL_HOME; 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( static int_t saveTestFixtureRemoveEntry(
const char *path, const char *path,
const struct stat *statBuffer, const struct stat *statBuffer,
@@ -49,6 +97,8 @@ int saveTestFixtureSetup(void **state) {
assertTrue(setenv("HOME", SAVE_TEST_FIXTURE_HOME, 1) == 0, "setenv failed"); assertTrue(setenv("HOME", SAVE_TEST_FIXTURE_HOME, 1) == 0, "setenv failed");
saveTestFixtureStubPartyAsset();
errorret_t ret = saveInit(); errorret_t ret = saveInit();
assertTrue(errorIsOk(ret), "saveInit failed in test fixture"); assertTrue(errorIsOk(ret), "saveInit failed in test fixture");
@@ -56,6 +106,8 @@ int saveTestFixtureSetup(void **state) {
} }
int saveTestFixtureTeardown(void **state) { int saveTestFixtureTeardown(void **state) {
saveTestFixtureUnstubPartyAsset();
nftw( nftw(
SAVE_TEST_FIXTURE_HOME, saveTestFixtureRemoveEntry, 16, SAVE_TEST_FIXTURE_HOME, saveTestFixtureRemoveEntry, 16,
FTW_DEPTH | FTW_PHYS FTW_DEPTH | FTW_PHYS
+18
View File
@@ -35,3 +35,21 @@ int saveTestFixtureTeardown(void **state);
* filesystem permission tricks. Must be called after saveTestFixtureSetup. * filesystem permission tricks. Must be called after saveTestFixtureSetup.
*/ */
void saveTestFixtureBlockSaveDirectory(void); 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) { static void test_saveSaveSlot_success(void **state) {
makeDeviceAvailable(); makeDeviceAvailable();
SAVE.slotCurrent = 1; SAVE.slotCurrent = 1;
saveSlotInit(); saveSlotInit(&SAVE.slot);
stringCopy( stringCopy(
SAVE.slot.cachedData.name, "Hero", sizeof(SAVE.slot.cachedData.name) 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 // ever operates on SAVE.slot now), then reset it again below - the
// write and the load-under-test don't overlap in time. // write and the load-under-test don't overlap in time.
SAVE.slotCurrent = 2; SAVE.slotCurrent = 2;
saveSlotInit(); saveSlotInit(&SAVE.slot);
stringCopy( stringCopy(
SAVE.slot.cachedData.name, "Zelda", sizeof(SAVE.slot.cachedData.name) 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)); assert_true(errorIsOk(writeRet));
SAVE.slotCurrent = 2; SAVE.slotCurrent = 2;
saveSlotInit(); saveSlotInit(&SAVE.slot);
SAVE.slotDirty = true; SAVE.slotDirty = true;
errorret_t ret = saveLoadSlot(); errorret_t ret = saveLoadSlot();
@@ -371,7 +371,7 @@ static void test_saveLoadSlot_deviceReadFails_cacheStaysStale(void **state) {
fclose(file); fclose(file);
SAVE.slotCurrent = 0; SAVE.slotCurrent = 0;
saveSlotInit(); saveSlotInit(&SAVE.slot);
SAVE.caches[0].playerLevel = 77;// sentinel SAVE.caches[0].playerLevel = 77;// sentinel
errorret_t ret = saveLoadSlot(); 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++) { for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) {
SAVE.slotCurrent = i; SAVE.slotCurrent = i;
saveSlotInit(); saveSlotInit(&SAVE.slot);
SAVE.slot.cachedData.playerLevel = (int32_t)(i + 1); SAVE.slot.cachedData.playerLevel = (int32_t)(i + 1);
errorret_t saveRet = saveSaveSlot(); errorret_t saveRet = saveSaveSlot();
assert_true(errorIsOk(saveRet)); assert_true(errorIsOk(saveRet));
@@ -410,7 +410,7 @@ static void test_saveLoadAllSlots_missingFileLeavesInitDefaults(void **state) {
makeDeviceAvailable(); makeDeviceAvailable();
SAVE.slotCurrent = 0; SAVE.slotCurrent = 0;
saveSlotInit(); saveSlotInit(&SAVE.slot);
SAVE.slot.cachedData.playerLevel = 5; SAVE.slot.cachedData.playerLevel = 5;
errorret_t saveRet = saveSaveSlot(); errorret_t saveRet = saveSaveSlot();
assert_true(errorIsOk(saveRet)); assert_true(errorIsOk(saveRet));
@@ -433,7 +433,7 @@ static void test_saveLoadAllSlots_middleSlotCorrupt_stopsAtFailingIndex(
makeDeviceAvailable(); makeDeviceAvailable();
SAVE.slotCurrent = 0; SAVE.slotCurrent = 0;
saveSlotInit(); saveSlotInit(&SAVE.slot);
errorret_t saveRet = saveSaveSlot(); errorret_t saveRet = saveSaveSlot();
assert_true(errorIsOk(saveRet)); 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 // saveSlotInit() only ever operates on SAVE.slot now, so that's the
// write source here. // write source here.
saveSlotInit(); saveSlotInit(&SAVE.slot);
SAVE.slot.cachedData.playerLevel = 21; SAVE.slot.cachedData.playerLevel = 21;
errorret_t writeRet = saveDeviceSlotWrite(&device, &SAVE.slot, 0); 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 // saveSlotInit() only ever operates on SAVE.slot now, so that's the
// write source here. // write source here.
saveSlotInit(); saveSlotInit(&SAVE.slot);
stringCopy( stringCopy(
SAVE.slot.cachedData.name, "Hero", sizeof(SAVE.slot.cachedData.name) 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/slot/saveslot.h"
#include "save/save.h" #include "save/save.h"
#include "save/savejson.h" #include "save/savejson.h"
#include "savetestfixture.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h" #include "util/string.h"
#include <stdlib.h> #include <stdlib.h>
#include <string.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 // Helpers - wrap the writeInit/readInit macros (which can only be used
// inside a function returning errorret_t) so tests can call them plainly. // 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) { static void test_saveSlotInit_defaults(void **state) {
memorySet(&SAVE.slot, 0xFF, sizeof(SAVE.slot)); memorySet(&SAVE.slot, 0xFF, sizeof(SAVE.slot));
saveSlotInit(); saveSlotInit(&SAVE.slot);
assert_int_equal(SAVE.slot.version, 1); assert_int_equal(SAVE.slot.version, 1);
assert_int_equal(SAVE.slot.dataType, 0); assert_int_equal(SAVE.slot.dataType, 0);
@@ -66,7 +80,7 @@ static void test_saveSlotInit_defaults(void **state) {
// ============================================================ // ============================================================
static void test_saveSlotInUse(void **state) { static void test_saveSlotInUse(void **state) {
saveSlotInit(); saveSlotInit(&SAVE.slot);
assert_false(saveSlotInUse(&SAVE.slot.cachedData)); assert_false(saveSlotInUse(&SAVE.slot.cachedData));
@@ -81,7 +95,7 @@ static void test_saveSlotInUse_nullAsserts(void **state) {
} }
static void test_saveSlotHasSaved(void **state) { static void test_saveSlotHasSaved(void **state) {
saveSlotInit(); saveSlotInit(&SAVE.slot);
assert_false(saveSlotHasSaved(&SAVE.slot.cachedData)); assert_false(saveSlotHasSaved(&SAVE.slot.cachedData));
@@ -136,7 +150,7 @@ static void test_saveSlotReadJSON_nullAsserts(void **state) {
static void test_saveSlotReadJSON_roundTrip(void **state) { static void test_saveSlotReadJSON_roundTrip(void **state) {
// saveSlotInit() only ever operates on SAVE.slot now, so that's the // saveSlotInit() only ever operates on SAVE.slot now, so that's the
// write source here. // write source here.
saveSlotInit(); saveSlotInit(&SAVE.slot);
SAVE.slot.version = 7;// deliberately non-default, not part of the JSON schema SAVE.slot.version = 7;// deliberately non-default, not part of the JSON schema
stringCopy( stringCopy(
SAVE.slot.cachedData.name, "Hero", sizeof(SAVE.slot.cachedData.name) SAVE.slot.cachedData.name, "Hero", sizeof(SAVE.slot.cachedData.name)
@@ -296,5 +310,5 @@ int main(void) {
cmocka_unit_test(test_saveSlotReadJSON_versionMismatch_errors), cmocka_unit_test(test_saveSlotReadJSON_versionMismatch_errors),
}; };
return cmocka_run_group_tests(tests, NULL, NULL); return cmocka_run_group_tests(tests, assetGroupSetup, assetGroupTeardown);
} }