From 0566166af4025980068745326c4ba8f51fa60967 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Thu, 10 Sep 2026 14:19:03 -0500 Subject: [PATCH] Persist party across saves and flag/warn on corrupt save slots Party moves off a global singleton onto each save slot, letting START_BATTLE pick specific active-order members to bring into a fight, and round-trips through save/load via saveslotver1. A corrupt slot load now flags the cache and shows a red "Corrupted Save" warning in the select-save UI instead of silently resetting. Co-Authored-By: Claude Sonnet 5 --- assets/cutscenes/battletest.jsonc | 24 ++++ assets/locale/en_US.po | 6 + assets/maps/overworld/chunks/chunk_0_0_0.json | 2 +- src/dusk/rpg/battle/party.c | 41 ++++--- src/dusk/rpg/battle/party.h | 21 ++-- .../rpg/cutscene/item/battle/CMakeLists.txt | 1 + .../item/battle/cutsceneregularbattle.c | 15 +++ .../item/battle/cutsceneregularbattle.h | 26 ++++ .../item/battle/cutscenestartbattle.c | 48 +++++++- .../item/battle/cutscenestartbattle.h | 8 ++ src/dusk/rpg/cutscene/item/cutsceneitem.c | 6 + src/dusk/rpg/cutscene/item/cutsceneitem.h | 2 + src/dusk/rpg/entity/global/entitygloballist.h | 7 ++ src/dusk/rpg/rpg.c | 3 +- src/dusk/save/save.c | 1 + src/dusk/save/savejson.h | 113 ++++++++++++++++++ src/dusk/save/slot/saveslot.c | 5 + src/dusk/save/slot/saveslot.h | 7 ++ src/dusk/save/slot/saveslotcurrent.c | 11 ++ src/dusk/save/slot/saveslotver1.c | 57 +++++++++ src/dusk/save/slot/saveslotver1.h | 9 +- src/dusk/ui/dialog/save/uisaveslot.c | 14 ++- src/dusk/ui/dialog/save/uisaveslot.h | 18 +++ src/dusk/ui/dialog/save/uiselectsave.c | 29 +++++ 24 files changed, 441 insertions(+), 33 deletions(-) create mode 100644 assets/cutscenes/battletest.jsonc create mode 100644 src/dusk/rpg/cutscene/item/battle/cutsceneregularbattle.c create mode 100644 src/dusk/rpg/cutscene/item/battle/cutsceneregularbattle.h diff --git a/assets/cutscenes/battletest.jsonc b/assets/cutscenes/battletest.jsonc new file mode 100644 index 00000000..6039ec6b --- /dev/null +++ b/assets/cutscenes/battletest.jsonc @@ -0,0 +1,24 @@ +{ + "items": [ + { + "type": "START_BATTLE", + "encounterType": "REGULAR", + "fleeAvailable": true, + "enemies": [ + { + "stats": { "attack": 8, "defense": 4, "magic": 0, "speed": 5, "luck": 2 }, + "healthMax": 30, + "mpMax": 0 + }, + { + "stats": { "attack": 6, "defense": 3, "magic": 0, "speed": 7, "luck": 2 }, + "healthMax": 20, + "mpMax": 0 + } + ] + }, + { + "type": "REGULAR_BATTLE" + } + ] +} diff --git a/assets/locale/en_US.po b/assets/locale/en_US.po index 0c0599c7..d45ecd33 100644 --- a/assets/locale/en_US.po +++ b/assets/locale/en_US.po @@ -73,6 +73,12 @@ msgstr "Select Save" msgid "ui.select_save.empty" msgstr "Empty Slot" +msgid "ui.select_save.corrupt" +msgstr "Corrupted Save" + +msgid "ui.select_save.corrupt_warning" +msgstr "This save file is corrupted and could not be loaded. Please contact support if this issue persists." + msgid "ui.select_save.slot_format" msgstr "%s Lv.%d %s" diff --git a/assets/maps/overworld/chunks/chunk_0_0_0.json b/assets/maps/overworld/chunks/chunk_0_0_0.json index abf925fd..ec0a47e0 100644 --- a/assets/maps/overworld/chunks/chunk_0_0_0.json +++ b/assets/maps/overworld/chunks/chunk_0_0_0.json @@ -1 +1 @@ -{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[6,0],[2,0],[2,0],[2,0],[2,0],[2,0],[2,0],[7,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[4,0],[12,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[9,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[{"file":"house_5_3.dmf","pos":[1,1,0]}],"entities":[{"type":"global","globalId":3,"pos":[8,8,1]},{"type":"item","itemId":1,"quantity":1,"pos":[12,2,0]}],"areas":[{"min":[11,3,0],"max":[16,9,10],"callbackId":1,"notify":3,"trigger":6}]} \ No newline at end of file +{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[6,0],[2,0],[2,0],[2,0],[2,0],[2,0],[2,0],[7,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[4,0],[12,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[9,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[{"file":"house_5_3.dmf","pos":[1,1,0]}],"entities":[{"type":"global","globalId":3,"pos":[8,8,1]},{"type":"global","globalId":4,"pos":[12,2,0]}],"areas":[{"min":[11,3,0],"max":[16,9,10],"callbackId":1,"notify":3,"trigger":6}]} \ No newline at end of file diff --git a/src/dusk/rpg/battle/party.c b/src/dusk/rpg/battle/party.c index 0350949d..f1ed9493 100644 --- a/src/dusk/rpg/battle/party.c +++ b/src/dusk/rpg/battle/party.c @@ -9,63 +9,72 @@ #include "assert/assert.h" #include "util/memory.h" -party_t PARTY; +void partyInit(party_t *party) { + assertNotNull(party, "Party cannot be null"); -void partyInit(void) { - 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; + party->members[i].id = 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) { +uint8_t partyGetAvailableMember(const party_t *party) { + assertNotNull(party, "Party cannot be null"); + for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) { - if(PARTY.members[i].status == BATTLE_FIGHTER_STATUS_NULL) return i; + if(party->members[i].status == BATTLE_FIGHTER_STATUS_NULL) return i; } return 0xFF; } battlefighter_t *partyAddMember( + party_t *party, const battlefighterstats_t stats, const uint16_t healthMax, const uint16_t mpMax ) { - const uint8_t index = partyGetAvailableMember(); + assertNotNull(party, "Party cannot be null"); + + const uint8_t index = partyGetAvailableMember(party); if(index == 0xFF) return NULL; - battlefighter_t *member = &PARTY.members[index]; + battlefighter_t *member = &party->members[index]; battleFighterInit( member, BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, stats, healthMax, mpMax ); for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) { - if(PARTY.order[i] != PARTY_ORDER_EMPTY) continue; - PARTY.order[i] = index; + if(party->order[i] != PARTY_ORDER_EMPTY) continue; + party->order[i] = index; break; } return member; } -battlefighter_t *partyGetOrderMember(const uint8_t slot) { +battlefighter_t *partyGetOrderMember(party_t *party, const uint8_t slot) { + assertNotNull(party, "Party cannot be null"); assertTrue(slot < PARTY_ACTIVE_SIZE_MAX, "Invalid party order slot"); - const uint8_t index = PARTY.order[slot]; + const uint8_t index = party->order[slot]; if(index == PARTY_ORDER_EMPTY) return NULL; - return &PARTY.members[index]; + return &party->members[index]; } -void partySetOrder(const uint8_t slot, const uint8_t memberIndex) { +void partySetOrder( + party_t *party, const uint8_t slot, const uint8_t memberIndex +) { + assertNotNull(party, "Party cannot be null"); assertTrue(slot < PARTY_ACTIVE_SIZE_MAX, "Invalid party order slot"); assertTrue( memberIndex == PARTY_ORDER_EMPTY || memberIndex < PARTY_MEMBER_COUNT_MAX, "Invalid party member index" ); - PARTY.order[slot] = memberIndex; + party->order[slot] = memberIndex; } diff --git a/src/dusk/rpg/battle/party.h b/src/dusk/rpg/battle/party.h index 5f958169..4485ae6f 100644 --- a/src/dusk/rpg/battle/party.h +++ b/src/dusk/rpg/battle/party.h @@ -22,25 +22,27 @@ typedef struct { uint8_t order[PARTY_ACTIVE_SIZE_MAX]; } party_t; -extern party_t PARTY; - /** - * Initializes the party system with an empty roster and order. + * Initializes the party with an empty roster and order. + * + * @param party The party to initialize. */ -void partyInit(void); +void partyInit(party_t *party); /** * Gets an available (unused) party member slot index. * + * @param party The party to query. * @return The index of an available slot, or 0xFF if the party is full. */ -uint8_t partyGetAvailableMember(void); +uint8_t partyGetAvailableMember(const party_t *party); /** * Adds a member to the 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. * + * @param party The party to add the member to. * @param stats The member's base combat stats. * @param healthMax The member's maximum health. * @param mpMax The member's maximum mp. @@ -48,6 +50,7 @@ uint8_t partyGetAvailableMember(void); * already full. */ battlefighter_t *partyAddMember( + party_t *party, const battlefighterstats_t stats, const uint16_t healthMax, const uint16_t mpMax @@ -56,18 +59,22 @@ battlefighter_t *partyAddMember( /** * Gets the roster member currently occupying an active order slot. * + * @param party The party to query. * @param slot The active order slot to query. * @return Pointer to the member in that slot, or NULL if the slot is * empty. */ -battlefighter_t *partyGetOrderMember(const uint8_t slot); +battlefighter_t *partyGetOrderMember(party_t *party, const uint8_t slot); /** * Assigns a roster member to an active order slot, replacing whatever * was there. Use PARTY_ORDER_EMPTY to clear a slot. * + * @param party The party to modify. * @param slot The active order slot to assign. * @param memberIndex The roster member index to place there, or * PARTY_ORDER_EMPTY to clear the slot. */ -void partySetOrder(const uint8_t slot, const uint8_t memberIndex); +void partySetOrder( + party_t *party, const uint8_t slot, const uint8_t memberIndex +); diff --git a/src/dusk/rpg/cutscene/item/battle/CMakeLists.txt b/src/dusk/rpg/cutscene/item/battle/CMakeLists.txt index 60ee1359..60a5f108 100644 --- a/src/dusk/rpg/cutscene/item/battle/CMakeLists.txt +++ b/src/dusk/rpg/cutscene/item/battle/CMakeLists.txt @@ -8,4 +8,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} cutscenestartbattle.c cutscenebattlewaitstate.c cutscenebattleforceaction.c + cutsceneregularbattle.c ) diff --git a/src/dusk/rpg/cutscene/item/battle/cutsceneregularbattle.c b/src/dusk/rpg/cutscene/item/battle/cutsceneregularbattle.c new file mode 100644 index 00000000..a753d0e5 --- /dev/null +++ b/src/dusk/rpg/cutscene/item/battle/cutsceneregularbattle.c @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "rpg/cutscene/item/cutsceneitem.h" + +bool_t cutsceneRegularBattleUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + return false; +} diff --git a/src/dusk/rpg/cutscene/item/battle/cutsceneregularbattle.h b/src/dusk/rpg/cutscene/item/battle/cutsceneregularbattle.h new file mode 100644 index 00000000..05d432ae --- /dev/null +++ b/src/dusk/rpg/cutscene/item/battle/cutsceneregularbattle.h @@ -0,0 +1,26 @@ +/** + * 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" + +/** + * Updates a regular-battle step. Currently a placeholder - behaves exactly + * like IDLE (see cutsceneidle.h) and never completes on its own. + * + * @param item The cutscene item. + * @param data Runtime data storage. + * @returns false always. + */ +bool_t cutsceneRegularBattleUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); + +// REGULAR_BATTLE has no fields of its own to parse yet - see +// cutsceneItemLoadNoop (cutsceneitembase.h), used directly as its .load +// callback. diff --git a/src/dusk/rpg/cutscene/item/battle/cutscenestartbattle.c b/src/dusk/rpg/cutscene/item/battle/cutscenestartbattle.c index 743a7cb6..ad6e2ee2 100644 --- a/src/dusk/rpg/cutscene/item/battle/cutscenestartbattle.c +++ b/src/dusk/rpg/cutscene/item/battle/cutscenestartbattle.c @@ -8,6 +8,7 @@ #include "rpg/cutscene/item/cutsceneitem.h" #include "rpg/battle/party.h" #include "scene/scene.h" +#include "save/save.h" #include "rpg/cutscene/item/json/cutscenejsonbattleencounter.h" #include "rpg/cutscene/item/json/cutscenejsonfield.h" @@ -19,8 +20,9 @@ void cutsceneStartBattleStart( battleInit(); - for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) { - battlefighter_t *member = partyGetOrderMember(i); + for(uint8_t i = 0; i < config->playerCount; i++) { + battlefighter_t *member = + partyGetOrderMember(&SAVE.slot.party, config->players[i]); if(member == NULL) continue; battlefighter_t *fighter = battleAddFighter( @@ -52,13 +54,15 @@ bool_t cutsceneStartBattleUpdate( ) { if(BATTLE.result == BATTLE_RESULT_NONE) return false; + const cutscenestartbattle_t *config = &item->startBattle; + // Sync ally HP/MP back to the persistent party roster. Relies on // ally fighters having been added to BATTLE.fighters in the same - // order partyGetOrderMember() iterates, starting at index 0 (see - // cutsceneStartBattleStart). + // order config->players iterates (see cutsceneStartBattleStart). uint8_t allySlot = 0; - for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) { - battlefighter_t *member = partyGetOrderMember(i); + for(uint8_t i = 0; i < config->playerCount; i++) { + battlefighter_t *member = + partyGetOrderMember(&SAVE.slot.party, config->players[i]); if(member == NULL) continue; battlefighter_t *fighter = &BATTLE.fighters[allySlot++]; @@ -89,6 +93,38 @@ errorret_t cutsceneStartBattleLoad( item->startBattle.fleeAvailable = cutsceneJsonGetBool(itemObj, "fleeAvailable", true); + yyjson_val *players = yyjson_obj_get(itemObj, "players"); + if(players == NULL) { + item->startBattle.playerCount = PARTY_ACTIVE_SIZE_MAX; + for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) { + item->startBattle.players[i] = i; + } + } else { + size_t playerCount = yyjson_arr_size(players); + if(!yyjson_is_arr(players) || playerCount > PARTY_ACTIVE_SIZE_MAX) { + errorThrow( + "Cutscene item (START_BATTLE): 'players' must have 0-%u entries", + (uint32_t)PARTY_ACTIVE_SIZE_MAX + ); + } + item->startBattle.playerCount = (uint8_t)playerCount; + + yyjson_arr_iter playerIter = yyjson_arr_iter_with(players); + yyjson_val *playerVal; + size_t p = 0; + while((playerVal = yyjson_arr_iter_next(&playerIter)) != NULL) { + int64_t index = yyjson_get_int(playerVal); + if(index < 0 || index >= PARTY_ACTIVE_SIZE_MAX) { + errorThrow( + "Cutscene item (START_BATTLE): players[%u] must be 0-%u", + (uint32_t)p, (uint32_t)(PARTY_ACTIVE_SIZE_MAX - 1) + ); + } + item->startBattle.players[p] = (uint8_t)index; + p++; + } + } + yyjson_val *enemies = yyjson_obj_get(itemObj, "enemies"); size_t enemyCount = yyjson_arr_size(enemies); if(!yyjson_is_arr(enemies) || enemyCount == 0 || diff --git a/src/dusk/rpg/cutscene/item/battle/cutscenestartbattle.h b/src/dusk/rpg/cutscene/item/battle/cutscenestartbattle.h index 341a7d06..8bf740f3 100644 --- a/src/dusk/rpg/cutscene/item/battle/cutscenestartbattle.h +++ b/src/dusk/rpg/cutscene/item/battle/cutscenestartbattle.h @@ -7,6 +7,7 @@ #pragma once #include "rpg/battle/battle.h" +#include "rpg/battle/party.h" #include "rpg/cutscene/item/cutsceneitembase.h" #define CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX 4 @@ -22,6 +23,13 @@ typedef struct { bool_t fleeAvailable; uint8_t enemyCount; cutscenestartbattleenemy_t enemies[CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX]; + + // Active party order slots (see party.h) to bring into the battle, in + // the order they should be added. Defaults to every active slot, 0 + // through PARTY_ACTIVE_SIZE_MAX - 1 in order, when the item's JSON + // omits "players" - see cutsceneStartBattleLoad. + uint8_t players[PARTY_ACTIVE_SIZE_MAX]; + uint8_t playerCount; } cutscenestartbattle_t; /** diff --git a/src/dusk/rpg/cutscene/item/cutsceneitem.c b/src/dusk/rpg/cutscene/item/cutsceneitem.c index 3f6f0253..a81d4f98 100644 --- a/src/dusk/rpg/cutscene/item/cutsceneitem.c +++ b/src/dusk/rpg/cutscene/item/cutsceneitem.c @@ -327,6 +327,12 @@ cutsceneitemcallbacks_t CUTSCENE_ITEM_CALLBACKS[CUTSCENE_ITEM_TYPE_COUNT] = { .update = cutsceneUIShowUpdate, .jsonName = "UI_SHOW", .load = cutsceneUIShowLoad + }, + + [CUTSCENE_ITEM_TYPE_REGULAR_BATTLE] = { + .update = cutsceneRegularBattleUpdate, + .jsonName = "REGULAR_BATTLE", + .load = cutsceneItemLoadNoop } }; diff --git a/src/dusk/rpg/cutscene/item/cutsceneitem.h b/src/dusk/rpg/cutscene/item/cutsceneitem.h index 8f537fd2..2c5af59e 100644 --- a/src/dusk/rpg/cutscene/item/cutsceneitem.h +++ b/src/dusk/rpg/cutscene/item/cutsceneitem.h @@ -41,6 +41,7 @@ #include "battle/cutscenestartbattle.h" #include "battle/cutscenebattlewaitstate.h" #include "battle/cutscenebattleforceaction.h" +#include "battle/cutsceneregularbattle.h" #include "save/cutscenesavedevicecheck.h" #include "save/cutscenesaveloadallslots.h" #include "audio/cutsceneaudioplay.h" @@ -118,6 +119,7 @@ typedef enum { CUTSCENE_ITEM_TYPE_AUDIO_SET, CUTSCENE_ITEM_TYPE_IDLE, CUTSCENE_ITEM_TYPE_UI_SHOW, + CUTSCENE_ITEM_TYPE_REGULAR_BATTLE, CUTSCENE_ITEM_TYPE_COUNT } cutsceneitemtype_t; diff --git a/src/dusk/rpg/entity/global/entitygloballist.h b/src/dusk/rpg/entity/global/entitygloballist.h index 2207763c..cd22a50a 100644 --- a/src/dusk/rpg/entity/global/entitygloballist.h +++ b/src/dusk/rpg/entity/global/entitygloballist.h @@ -8,6 +8,7 @@ #pragma once #include "entityglobaldefs.h" #include "rpg/cutscene/scene/testcutscene.h" +#include "rpg/cutscene/item/cutsceneitem.h" ENTITY_GLOBAL_CALLBACK(3) { create->entity->data.npc.moveType = NPC_MOVE_TYPE_PATH; @@ -20,11 +21,17 @@ ENTITY_GLOBAL_CALLBACK(3) { create->entity->interact.data.cutscene = CUTSCENE_REFERENCE(TEST_TWO); } +ENTITY_GLOBAL_CALLBACK(4) { + create->entity->interact.type = ENTITY_INTERACT_CUTSCENE; + create->entity->interact.data.cutscene = cutsceneCutsceneResolve("battletest"); +} + static const entityglobaldef_t ENTITY_GLOBAL_LIST[] = { ENTITY_GLOBAL(ENTITY_GLOBAL_ID_NULL, ENTITY_TYPE_NULL, NULL), ENTITY_GLOBAL(ENTITY_GLOBAL_ID_PLAYER, ENTITY_TYPE_PLAYER, NULL), ENTITY_GLOBAL(3, ENTITY_TYPE_NPC, ENTITY_GLOBAL_REF(3)), + ENTITY_GLOBAL(4, ENTITY_TYPE_NPC, ENTITY_GLOBAL_REF(4)), }; //EOF diff --git a/src/dusk/rpg/rpg.c b/src/dusk/rpg/rpg.c index 59ae98ad..b5b075f2 100644 --- a/src/dusk/rpg/rpg.c +++ b/src/dusk/rpg/rpg.c @@ -12,6 +12,7 @@ #include "rpg/cutscene/cutscenesystem.h" #include "rpg/item/backpack.h" #include "rpg/battle/party.h" +#include "save/save.h" #include "ui/rpg/textbox/uitextboxminilist.h" #include "time/time.h" #include "rpgcamera.h" @@ -27,7 +28,7 @@ errorret_t rpgInit(void) { memoryZero(MAP_AREAS, sizeof(MAP_AREAS)); backpackInit(); - partyInit(); + partyInit(&SAVE.slot.party); cutsceneSystemInit(); errorChain(mapInit()); rpgCameraInit(); diff --git a/src/dusk/save/save.c b/src/dusk/save/save.c index bc7ae1ce..5691fa5e 100644 --- a/src/dusk/save/save.c +++ b/src/dusk/save/save.c @@ -246,6 +246,7 @@ errorret_t saveLoadSlot() { (uint32_t)SAVE.slotCurrent ); saveSlotInit(&SAVE.slot); + SAVE.slot.cachedData.corrupt = true; } diff --git a/src/dusk/save/savejson.h b/src/dusk/save/savejson.h index 82a8cd87..c80d1c32 100644 --- a/src/dusk/save/savejson.h +++ b/src/dusk/save/savejson.h @@ -353,6 +353,119 @@ } \ } +/** + * Attaches an already-built mutable value (e.g. a yyjson_mut_arr() of + * per-element objects the caller populated by hand) to the current JSON + * object under the given key. + * + * @param key The key to write to. + * @param value The yyjson_mut_val* to attach. + */ +#define writeValue(key, value) yyjson_mut_obj_add_val(doc, object, key, value) + +/** + * Appends a new JSON object to the given mutable array as one element and + * declares `object` in the calling scope pointing at it, shadowing any + * outer `object` - so the writeX macros above, once called inside the loop + * that builds `arr`, populate this element instead of the outer object. + * + * @param arr The yyjson_mut_val* array (from yyjson_mut_arr()) to append to. + */ +#define writeArrayObject(arr) \ + yyjson_mut_val *object = yyjson_mut_arr_add_obj(doc, arr) + +/** + * Requires a key on the current JSON object to be a JSON array of exactly + * the given size, throwing otherwise. Declares `dest` (a yyjson_val*) in + * the calling scope pointing at the array, ready for the caller to iterate + * (e.g. with yyjson_arr_foreach) - unlike the readXArray macros, this + * doesn't copy the elements out itself, since each element here is a + * nested object the caller parses by hand. + * + * @param key The key to read from. + * @param dest The yyjson_val* variable to declare, pointing at the array. + * @param count The exact number of elements the array must have. + */ +#define requireArrayOfSize(key, dest, count) \ + yyjson_val *dest = yyjson_obj_get(object, key); \ + if((dest) == NULL) errorThrow("Save JSON missing '%s' key", key); \ + if(yyjson_arr_size(dest) != (size_t)(count)) { \ + errorThrow( \ + "Save JSON array '%s' has wrong length, expected %d", \ + key, (int)(count) \ + ); \ + } + +/** + * Checks if the given key exists on the current JSON object. + * + * @param key The key to check for. + */ +#define hasUInt8Array(key) (yyjson_obj_get(object, key) != NULL) + +/** + * Errors if the given key does not exist on the current JSON object. + * + * @param key The key that must exist. + */ +#define requireUInt8Array(key) \ + if(!hasUInt8Array(key)) errorThrow("Save JSON missing '%s' key", key) + +/** + * Writes a uint8_t array to the current JSON object. + * + * @param key The key to write to. + * @param value The array to write. + * @param count Number of elements in the array. + */ +#define writeUInt8Array(key, value, count) { \ + yyjson_mut_val *saveJsonArr = yyjson_mut_arr(doc); \ + size_t saveJsonArrIdx = 0; \ + while(saveJsonArrIdx < (size_t)(count)) { \ + yyjson_mut_arr_add_uint( \ + doc, saveJsonArr, (uint64_t)(value)[saveJsonArrIdx] \ + ); \ + saveJsonArrIdx++; \ + } \ + yyjson_mut_obj_add_val(doc, object, key, saveJsonArr); \ +} + +/** + * Reads a uint8_t array from the current JSON object into dest, falling + * back to filling every element with def if the key is missing. Errors if + * the JSON array's length doesn't exactly match count. + * + * @param key The key to read from. + * @param dest The destination array to assign to. + * @param count Number of elements dest holds. + * @param def The default value to fill every element with if the key is + * missing. + */ +#define readUInt8Array(key, dest, count, def) { \ + yyjson_val *saveJsonArr = yyjson_obj_get(object, key); \ + if(saveJsonArr != NULL) { \ + if(yyjson_arr_size(saveJsonArr) != (size_t)(count)) { \ + errorThrow( \ + "Save JSON array '%s' has wrong length, expected %d", \ + key, (int)(count) \ + ); \ + } \ + size_t saveJsonArrIdx, saveJsonArrLen; \ + yyjson_val *saveJsonArrElem; \ + yyjson_arr_foreach( \ + saveJsonArr, saveJsonArrIdx, saveJsonArrLen, saveJsonArrElem \ + ) { \ + (dest)[saveJsonArrIdx] = (uint8_t)yyjson_get_uint(saveJsonArrElem); \ + } \ + } else { \ + size_t saveJsonArrIdx = 0; \ + while(saveJsonArrIdx < (size_t)(count)) { \ + (dest)[saveJsonArrIdx] = (uint8_t)(def); \ + saveJsonArrIdx++; \ + } \ + } \ +} + /** * Checks if the given key exists on the current JSON object. * diff --git a/src/dusk/save/slot/saveslot.c b/src/dusk/save/slot/saveslot.c index ee09c40a..383bdceb 100644 --- a/src/dusk/save/slot/saveslot.c +++ b/src/dusk/save/slot/saveslot.c @@ -14,7 +14,10 @@ void saveSlotInit(saveslot_t *slot) { assertNotNull(slot, "Slot cannot be null"); memorySet(slot, 0, sizeof(saveslot_t)); + slot->version = 1; + + partyInit(&slot->party); } bool_t saveSlotInUse(saveslotcache_t *slot) { @@ -37,6 +40,8 @@ errorret_t saveSlotWriteJSON( } errorret_t saveSlotReadJSON(saveslot_t *slot, yyjson_val *object) { + saveSlotInit(slot); + errorChain(saveSlotCurrentReadJSON(slot, object)); errorOk(); } diff --git a/src/dusk/save/slot/saveslot.h b/src/dusk/save/slot/saveslot.h index 6920f204..67d88adb 100644 --- a/src/dusk/save/slot/saveslot.h +++ b/src/dusk/save/slot/saveslot.h @@ -8,6 +8,7 @@ #pragma once #include "save/savedevice.h" #include "rpg/overworld/map.h" +#include "rpg/battle/party.h" #include "time/timeepoch.h" #include "yyjson.h" @@ -28,6 +29,11 @@ typedef struct { dusktimeepoch_t time; int32_t playerLevel; char_t mapName[MAP_NAME_MAX]; + + // Set when this slot's on-disk file failed to load (corrupt or an + // incompatible schema version) and was reset to an empty slot instead - + // see saveLoadSlot() (save/save.c). + bool_t corrupt; } saveslotcache_t; typedef struct saveslot_s { @@ -35,6 +41,7 @@ typedef struct saveslot_s { uint8_t dataType; saveslotcache_t cachedData; + party_t party; } saveslot_t; /** diff --git a/src/dusk/save/slot/saveslotcurrent.c b/src/dusk/save/slot/saveslotcurrent.c index d0e51692..ef10f108 100644 --- a/src/dusk/save/slot/saveslotcurrent.c +++ b/src/dusk/save/slot/saveslotcurrent.c @@ -8,6 +8,7 @@ #include "saveslotcurrent.h" #include "assert/assert.h" #include "util/string.h" +#include "util/memory.h" #include "time/time.h" errorret_t saveSlotCurrentWriteJSON( @@ -29,6 +30,11 @@ errorret_t saveSlotCurrentWriteJSON( ver1.playerLevel = slot->cachedData.playerLevel; stringCopy(ver1.mapName, slot->cachedData.mapName, sizeof(ver1.mapName)); + memoryCopy( + ver1.partyMembers, slot->party.members, sizeof(ver1.partyMembers) + ); + memoryCopy(ver1.partyOrder, slot->party.order, sizeof(ver1.partyOrder)); + errorChain(saveSlotVer1WriteJSON(&ver1, doc, object)); errorOk(); } @@ -47,5 +53,10 @@ errorret_t saveSlotCurrentReadJSON(saveslot_t *slot, yyjson_val *object) { slot->cachedData.mapName, ver1.mapName, sizeof(slot->cachedData.mapName) ); + memoryCopy( + slot->party.members, ver1.partyMembers, sizeof(slot->party.members) + ); + memoryCopy(slot->party.order, ver1.partyOrder, sizeof(slot->party.order)); + errorOk(); } diff --git a/src/dusk/save/slot/saveslotver1.c b/src/dusk/save/slot/saveslotver1.c index c11b3ffd..db6a95be 100644 --- a/src/dusk/save/slot/saveslotver1.c +++ b/src/dusk/save/slot/saveslotver1.c @@ -24,6 +24,28 @@ errorret_t saveSlotVer1WriteJSON( writeInt32("playerLevel", slot->playerLevel); writeString("mapName", slot->mapName); + yyjson_mut_val *partyMembersArr = yyjson_mut_arr(doc); + for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) { + battlefighter_t *member = &slot->partyMembers[i]; + writeArrayObject(partyMembersArr); + writeUInt32("id", member->id); + writeUInt32("status", member->status); + writeUInt32("team", member->team); + writeUInt32("controller", member->controller); + writeUInt32("health", member->health); + writeUInt32("healthMax", member->healthMax); + writeUInt32("mp", member->mp); + writeUInt32("mpMax", member->mpMax); + writeUInt32("statAttack", member->stats.attack); + writeUInt32("statDefense", member->stats.defense); + writeUInt32("statMagic", member->stats.magic); + writeUInt32("statSpeed", member->stats.speed); + writeUInt32("statLuck", member->stats.luck); + } + writeValue("partyMembers", partyMembersArr); + + writeUInt8Array("partyOrder", slot->partyOrder, PARTY_ACTIVE_SIZE_MAX); + errorOk(); } @@ -42,5 +64,40 @@ errorret_t saveSlotVer1ReadJSON(saveslotver1_t *slot, yyjson_val *object) { "mapName", slot->mapName, "", SAVE_SLOT_VER1_MAP_NAME_MAX - 1 ); + requireArrayOfSize( + "partyMembers", partyMembersArr, PARTY_MEMBER_COUNT_MAX + ); + + size_t memberIdx, memberLen; + yyjson_val *memberObj; + yyjson_arr_foreach( + partyMembersArr, memberIdx, memberLen, memberObj + ) { + battlefighter_t *member = &slot->partyMembers[memberIdx]; + // Shadows the outer `object` so readUInt32() below (which hardcodes + // that name) reads from this member's object instead. + yyjson_val *object = memberObj; + readUInt32("id", member->id, 0); + readUInt32("status", member->status, BATTLE_FIGHTER_STATUS_NULL); + readUInt32("team", member->team, BATTLE_FIGHTER_TEAM_ALLY); + readUInt32( + "controller", member->controller, BATTLE_FIGHTER_CONTROLLER_PLAYER + ); + readUInt32("health", member->health, 0); + readUInt32("healthMax", member->healthMax, 0); + readUInt32("mp", member->mp, 0); + readUInt32("mpMax", member->mpMax, 0); + readUInt32("statAttack", member->stats.attack, 0); + readUInt32("statDefense", member->stats.defense, 0); + readUInt32("statMagic", member->stats.magic, 0); + readUInt32("statSpeed", member->stats.speed, 0); + readUInt32("statLuck", member->stats.luck, 0); + } + + readUInt8Array( + "partyOrder", slot->partyOrder, PARTY_ACTIVE_SIZE_MAX, + PARTY_ORDER_EMPTY + ); + errorOk(); } diff --git a/src/dusk/save/slot/saveslotver1.h b/src/dusk/save/slot/saveslotver1.h index 48c696aa..6ad8fd01 100644 --- a/src/dusk/save/slot/saveslotver1.h +++ b/src/dusk/save/slot/saveslotver1.h @@ -9,12 +9,16 @@ #include "error/error.h" #include "time/timeepoch.h" #include "save/slot/saveslotver.h" +#include "rpg/battle/party.h" #include "yyjson.h" // This schema's own frozen copies of save/slot/saveslot.h's field sizes - // never reference SAVE_SLOT_NAME_LENGTH/MAP_NAME_MAX (or any other live // constant) here. Once shipped, this file's on-disk shape must never change -// underneath old save files, even if the live engine constants do. +// underneath old save files, even if the live engine constants do. The +// party arrays below are the exception - saveSlotVer1ReadJSON() checks their +// JSON array lengths explicitly, so PARTY_MEMBER_COUNT_MAX/ +// PARTY_ACTIVE_SIZE_MAX (rpg/battle/party.h) can be referenced directly. #define SAVE_SLOT_VER1_NAME_LENGTH 8 #define SAVE_SLOT_VER1_MAP_NAME_MAX 10 @@ -28,6 +32,9 @@ typedef struct saveslotver1_s { dusktimeepoch_t time; int32_t playerLevel; char_t mapName[SAVE_SLOT_VER1_MAP_NAME_MAX]; + + battlefighter_t partyMembers[PARTY_MEMBER_COUNT_MAX]; + uint8_t partyOrder[PARTY_ACTIVE_SIZE_MAX]; } saveslotver1_t; /** diff --git a/src/dusk/ui/dialog/save/uisaveslot.c b/src/dusk/ui/dialog/save/uisaveslot.c index 674c87f6..b4e90486 100644 --- a/src/dusk/ui/dialog/save/uisaveslot.c +++ b/src/dusk/ui/dialog/save/uisaveslot.c @@ -57,6 +57,16 @@ void uiSaveSlotSetHighlighted( saveSlot->highlighted = highlighted; } +bool_t uiSaveSlotIsCorrupt(const uisaveslot_t *saveSlot) { + assertNotNull(saveSlot, "Save slot cannot be NULL"); + return saveSlot->corrupt; +} + +void uiSaveSlotSetCorrupt(uisaveslot_t *saveSlot, const bool_t corrupt) { + assertNotNull(saveSlot, "Save slot cannot be NULL"); + saveSlot->corrupt = corrupt; +} + uisaveslotdisplaytype_t uiSaveSlotGetDisplayType(const uisaveslot_t *saveSlot) { assertNotNull(saveSlot, "Save slot cannot be NULL"); return saveSlot->displayType; @@ -85,7 +95,9 @@ errorret_t uiSaveSlotDraw( const float_t contentY = y + UI_FRAME_START_Y; color_t color; - if(saveSlot->highlighted) { + if(saveSlot->corrupt) { + color = COLOR_RED; + } else if(saveSlot->highlighted) { color = COLOR_RED; } else if(saveSlot->displayType == UI_SAVE_SLOT_DISPLAY_TYPE_DELETE) { color = COLOR_ORANGE; diff --git a/src/dusk/ui/dialog/save/uisaveslot.h b/src/dusk/ui/dialog/save/uisaveslot.h index 07799765..31b7ea43 100644 --- a/src/dusk/ui/dialog/save/uisaveslot.h +++ b/src/dusk/ui/dialog/save/uisaveslot.h @@ -42,6 +42,7 @@ typedef struct { spritebatchsprite_t nameSprites[UI_SAVE_SLOT_NAME_SPRITES_MAX]; bool_t highlighted; + bool_t corrupt; } uisaveslot_t; /** @@ -75,6 +76,23 @@ bool_t uiSaveSlotIsHighlighted(const uisaveslot_t *saveSlot); */ void uiSaveSlotSetHighlighted(uisaveslot_t *saveSlot, const bool_t highlighted); +/** + * Returns whether the save slot widget is flagged as corrupt. + * + * @param saveSlot The save slot widget to query. + * @returns True if flagged as corrupt. + */ +bool_t uiSaveSlotIsCorrupt(const uisaveslot_t *saveSlot); + +/** + * Sets whether the save slot widget is flagged as corrupt - drawn in red + * regardless of highlighted/display-type state, taking priority over both. + * + * @param saveSlot The save slot widget to update. + * @param corrupt The new corrupt state. + */ +void uiSaveSlotSetCorrupt(uisaveslot_t *saveSlot, const bool_t corrupt); + /** * Returns the save slot widget's display type. * diff --git a/src/dusk/ui/dialog/save/uiselectsave.c b/src/dusk/ui/dialog/save/uiselectsave.c index af37389f..81a8acb4 100644 --- a/src/dusk/ui/dialog/save/uiselectsave.c +++ b/src/dusk/ui/dialog/save/uiselectsave.c @@ -7,6 +7,7 @@ #include "uiselectsave.h" #include "ui/widget/uiframe.h" +#include "ui/widget/uimodal.h" #include "ui/dialog/uiconfirm.h" #include "ui/dialog/keyboard/uikeyboard.h" #include "ui/overlay/uifatalerror.h" @@ -31,6 +32,14 @@ void uiSelectSaveFormatSlot( ) { saveslotcache_t *cache = &SAVE.caches[index]; + if(cache->corrupt) { + errorret_t result = localeManagerGetString( + "ui.select_save.corrupt", buffer, bufferSize, 0 + ); + if(errorIsNotOk(result)) errorCatch(result); + return; + } + if(!saveSlotInUse(cache)) { errorret_t result = localeManagerGetString( "ui.select_save.empty", buffer, bufferSize, 0 @@ -58,6 +67,9 @@ void uiSelectSaveRefreshSlot(const uint8_t index) { UI_SELECT_SAVE.type == UI_SELECT_SAVE_TYPE_DELETE ? UI_SAVE_SLOT_DISPLAY_TYPE_DELETE : UI_SAVE_SLOT_DISPLAY_TYPE_NORMAL; uiSaveSlotSetDisplayType(&UI_SELECT_SAVE.slots[index], displayType); + uiSaveSlotSetCorrupt( + &UI_SELECT_SAVE.slots[index], SAVE.caches[index].corrupt + ); } void uiSelectSaveSetActionLabel(const uiselectsavetype_t type) { @@ -170,6 +182,18 @@ void uiSelectSaveNameEmptySlot(const uint8_t index) { uiKeyboardOpen(&open); } +void uiSelectSaveWarnCorrupt(void) { + const char_t *options[] = { "ui.confirm.confirm" }; + // Acknowledging just closes the warning - the player stays on the + // select-save list rather than being pushed into naming/overwriting + // the slot. + uiModalOpen( + "ui.select_save.corrupt", "ui.select_save.corrupt_warning", + options, 1, + NULL, NULL, NULL + ); +} + bool_t uiSelectSaveMenuCancelled(const uimenu_t *menu) { if(UI_SELECT_SAVE.type != UI_SELECT_SAVE_TYPE_DELETE) return false; @@ -195,6 +219,11 @@ void uiSelectSaveSlotSelected( return; } + if(SAVE.caches[index].corrupt) { + uiSelectSaveWarnCorrupt(); + return; + } + if(!saveSlotInUse(&SAVE.caches[index])) { uiSelectSaveNameEmptySlot(index); return;