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
}
]
}
+152 -28
View File
@@ -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;
}
assetentry_t *entry = assetLock("party.jsonc", ASSET_LOADER_TYPE_JSON, NULL);
errorret_t result = assetRequireLoaded(entry);
if(errorIsNotOk(result)) {
assetUnlockEntry(entry);
errorChain(result);
}
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;
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");
}
return 0xFF;
}
battlefighter_t *partyAddMember(
const battlefighterstats_t stats
) {
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
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();
}
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;
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);
}
}
+69 -17
View File
@@ -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);
@@ -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;
@@ -109,3 +109,12 @@ void battleStateSelectionPlayerAttack(const uint8_t targetIndex);
* BATTLE_STATE_SELECTION and awaiting a decision.
*/
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);
}
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");
+11
View File
@@ -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.
*
@@ -21,3 +21,4 @@ add_subdirectory(save)
add_subdirectory(audio)
add_subdirectory(json)
add_subdirectory(story)
add_subdirectory(party)
@@ -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,
+29
View File
@@ -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
}
};
@@ -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;
};
};
@@ -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;
@@ -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(battleMoveInit());
backpackInit();
partyInit();
cutsceneSystemInit();
errorChain(mapInit());
rpgCameraInit();
+4 -4
View File
@@ -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.
}
+21 -22
View File
@@ -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();
+16 -11
View File
@@ -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
+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
// 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
+7
View File
@@ -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();
}
+7 -1
View File
@@ -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));
+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);
}