From fb4828514392f9db34eb563e27bc9d59e8213f11 Mon Sep 17 00:00:00 2001 From: Dominic Masters Date: Thu, 6 Aug 2026 08:49:42 -0500 Subject: [PATCH] Rebuild battle flow as an explicit state machine with cutscene hooks Replace the one-fighter-at-a-time turn model with an OPENING/PRE_ROUND/ PLAYER_SELECTION/AI_SELECTION/MOVES_EXECUTING/POST_ROUND/ENDED state machine and a per-fighter action queue, so actions are decided before any of them execute (needed for speed-ordered resolution) and so a cutscene can pause the battle, wait for a specific state, and force a fighter's action -- enabling automated, fully-scripted, and partially-scripted battles. Adds CUTSCENE_PAUSE_BATTLE plus CUTSCENE_BATTLE_WAIT_STATE and CUTSCENE_BATTLE_FORCE_ACTION cutscene items, and generic onStateChanged/ onActionDecided callbacks on battle_t. Re-enables the long-dormant test/rpg suite and adds test/rpg/battle covering the state machine and the new cutscene hooks end-to-end. Co-Authored-By: Claude Sonnet 5 --- src/dusk/rpg/battle/battle.c | 219 ++++++--- src/dusk/rpg/battle/battle.h | 191 ++++++-- src/dusk/rpg/cutscene/cutscene.h | 22 + src/dusk/rpg/cutscene/cutscenepause.h | 4 +- .../rpg/cutscene/item/battle/CMakeLists.txt | 2 + .../item/battle/cutscenebattleforceaction.c | 25 + .../item/battle/cutscenebattleforceaction.h | 42 ++ .../item/battle/cutscenebattlewaitstate.c | 15 + .../item/battle/cutscenebattlewaitstate.h | 30 ++ src/dusk/rpg/cutscene/item/cutsceneitem.c | 9 + src/dusk/rpg/cutscene/item/cutsceneitem.h | 6 + test/CMakeLists.txt | 2 +- test/rpg/CMakeLists.txt | 3 +- test/rpg/battle/CMakeLists.txt | 9 + test/rpg/battle/test_battle.c | 443 ++++++++++++++++++ 15 files changed, 925 insertions(+), 97 deletions(-) create mode 100644 src/dusk/rpg/cutscene/item/battle/cutscenebattleforceaction.c create mode 100644 src/dusk/rpg/cutscene/item/battle/cutscenebattleforceaction.h create mode 100644 src/dusk/rpg/cutscene/item/battle/cutscenebattlewaitstate.c create mode 100644 src/dusk/rpg/cutscene/item/battle/cutscenebattlewaitstate.h create mode 100644 test/rpg/battle/CMakeLists.txt create mode 100644 test/rpg/battle/test_battle.c diff --git a/src/dusk/rpg/battle/battle.c b/src/dusk/rpg/battle/battle.c index cdba1b95..e0d0a5a8 100644 --- a/src/dusk/rpg/battle/battle.c +++ b/src/dusk/rpg/battle/battle.c @@ -8,6 +8,7 @@ #include "battle.h" #include "assert/assert.h" #include "util/memory.h" +#include "rpg/cutscene/cutscenesystem.h" battle_t BATTLE; @@ -51,10 +52,8 @@ void battleStart( BATTLE.fleeAvailable = fleeAvailable; BATTLE.result = BATTLE_RESULT_NONE; BATTLE.round = 1; - BATTLE.turnIndex = 0; - battleBuildTurnOrder(true); - BATTLE.active = true; + battleSetState(BATTLE_STATE_OPENING); } void battleDispose(void) { @@ -62,9 +61,9 @@ void battleDispose(void) { } battlefighter_t *battleGetCurrentFighter(void) { - if(!BATTLE.active) return NULL; - if(BATTLE.turnIndex >= BATTLE.turnCount) return NULL; - return &BATTLE.fighters[BATTLE.turnOrder[BATTLE.turnIndex]]; + if(BATTLE.state != BATTLE_STATE_PLAYER_SELECTION) return NULL; + if(BATTLE.selectionIndex >= BATTLE.executionCount) return NULL; + return &BATTLE.fighters[BATTLE.executionOrder[BATTLE.selectionIndex]]; } uint8_t battleGetAliveCount(const battlefighterteam_t team) { @@ -92,91 +91,105 @@ void battleResolveAttack( if(defender->health == 0) defender->status = BATTLE_FIGHTER_STATUS_DEAD; } -void battleNextTurn(void) { - BATTLE.turnIndex++; - if(BATTLE.turnIndex < BATTLE.turnCount) return; - - BATTLE.round++; - BATTLE.turnIndex = 0; - battleBuildTurnOrder(false); -} - battleresult_t battleCheckResult(void) { if(BATTLE.result != BATTLE_RESULT_NONE) return BATTLE.result; if(battleGetAliveCount(BATTLE_FIGHTER_TEAM_ALLY) == 0) { - BATTLE.result = BATTLE_RESULT_LOSS; + battleSetResult(BATTLE_RESULT_LOSS); } else if(battleGetAliveCount(BATTLE_FIGHTER_TEAM_ENEMY) == 0) { - BATTLE.result = BATTLE_RESULT_WIN; + battleSetResult(BATTLE_RESULT_WIN); } return BATTLE.result; } +void battleQueueAction( + const uint8_t fighterIndex, + const battleactiontype_t type, + const uint8_t targetIndex +) { + battleaction_t *action = &BATTLE.actions[fighterIndex]; + action->type = type; + action->targetIndex = targetIndex; + + if(BATTLE.onActionDecided != NULL) { + BATTLE.onActionDecided(&BATTLE.fighters[fighterIndex], action); + } +} + void battlePlayerAttack(const uint8_t targetIndex) { - battlefighter_t *attacker = battleGetCurrentFighter(); - if(attacker == NULL) return; - if(attacker->controller != BATTLE_FIGHTER_CONTROLLER_PLAYER) return; + battlefighter_t *fighter = battleGetCurrentFighter(); + if(fighter == NULL) return; if(targetIndex >= BATTLE_FIGHTER_COUNT_MAX) return; + if(!battleFighterIsAlive(&BATTLE.fighters[targetIndex])) return; - battlefighter_t *defender = &BATTLE.fighters[targetIndex]; - if(!battleFighterIsAlive(defender)) return; - - battleResolveAttack(attacker, defender); - battleCheckResult(); - if(BATTLE.result == BATTLE_RESULT_NONE) battleNextTurn(); + battleQueueAction(fighter->id, BATTLE_ACTION_ATTACK, targetIndex); + BATTLE.selectionIndex++; + battleAdvanceSelection(); } void battlePlayerFlee(void) { battlefighter_t *fighter = battleGetCurrentFighter(); if(fighter == NULL) return; - if(fighter->controller != BATTLE_FIGHTER_CONTROLLER_PLAYER) return; if(!BATTLE.fleeAvailable) return; - BATTLE.result = BATTLE_RESULT_FLED; + battleSetResult(BATTLE_RESULT_FLED); } void battleUpdate(void) { - if(!BATTLE.active) return; - if(BATTLE.result != BATTLE_RESULT_NONE) return; + if(BATTLE.state == BATTLE_STATE_NONE) return; + if(BATTLE.state == BATTLE_STATE_ENDED) return; + if(CUTSCENE_SYSTEM.pause & CUTSCENE_PAUSE_BATTLE) return; - battlefighter_t *current = battleGetCurrentFighter(); - if(current == NULL) return; + switch(BATTLE.state) { + case BATTLE_STATE_OPENING: + battleSetState(BATTLE_STATE_PRE_ROUND); + break; - if(!battleFighterIsAlive(current)) { - battleNextTurn(); - return; + case BATTLE_STATE_PRE_ROUND: + battleUpdatePreRound(); + break; + + case BATTLE_STATE_AI_SELECTION: + battleUpdateAiSelection(); + break; + + case BATTLE_STATE_MOVES_EXECUTING: + battleUpdateMovesExecuting(); + break; + + case BATTLE_STATE_POST_ROUND: + battleUpdatePostRound(); + break; + + default: + // BATTLE_STATE_PLAYER_SELECTION: waits on battlePlayerAttack/Flee. + // BATTLE_STATE_NONE/ENDED: handled above. + break; } - - if(current->controller != BATTLE_FIGHTER_CONTROLLER_AI) return; - - battlefighter_t *target = battleAIChooseTarget(current); - if(target != NULL) battleResolveAttack(current, target); - - battleCheckResult(); - if(BATTLE.result == BATTLE_RESULT_NONE) battleNextTurn(); } -void battleBuildTurnOrder(const bool_t applyEncounterBias) { - BATTLE.turnCount = 0; +void battleBuildExecutionOrder(const bool_t applyEncounterBias) { + BATTLE.executionCount = 0; for(uint8_t i = 0; i < BATTLE_FIGHTER_COUNT_MAX; i++) { if(!battleFighterIsAlive(&BATTLE.fighters[i])) continue; - BATTLE.turnOrder[BATTLE.turnCount++] = i; + BATTLE.executionOrder[BATTLE.executionCount++] = i; } // Insertion sort by speed descending -- fine for BATTLE_FIGHTER_COUNT_MAX. - for(uint8_t i = 1; i < BATTLE.turnCount; i++) { - const uint8_t key = BATTLE.turnOrder[i]; + for(uint8_t i = 1; i < BATTLE.executionCount; i++) { + const uint8_t key = BATTLE.executionOrder[i]; const uint16_t keySpeed = BATTLE.fighters[key].stats.speed; int8_t j = (int8_t)i - 1; while( - j >= 0 && BATTLE.fighters[BATTLE.turnOrder[j]].stats.speed < keySpeed + j >= 0 && + BATTLE.fighters[BATTLE.executionOrder[j]].stats.speed < keySpeed ) { - BATTLE.turnOrder[j + 1] = BATTLE.turnOrder[j]; + BATTLE.executionOrder[j + 1] = BATTLE.executionOrder[j]; j--; } - BATTLE.turnOrder[j + 1] = key; + BATTLE.executionOrder[j + 1] = key; } if(!applyEncounterBias) return; @@ -192,16 +205,16 @@ void battleMoveTeamFirst(const battlefighterteam_t team) { uint8_t sorted[BATTLE_FIGHTER_COUNT_MAX]; uint8_t count = 0; - for(uint8_t i = 0; i < BATTLE.turnCount; i++) { - if(BATTLE.fighters[BATTLE.turnOrder[i]].team != team) continue; - sorted[count++] = BATTLE.turnOrder[i]; + for(uint8_t i = 0; i < BATTLE.executionCount; i++) { + if(BATTLE.fighters[BATTLE.executionOrder[i]].team != team) continue; + sorted[count++] = BATTLE.executionOrder[i]; } - for(uint8_t i = 0; i < BATTLE.turnCount; i++) { - if(BATTLE.fighters[BATTLE.turnOrder[i]].team == team) continue; - sorted[count++] = BATTLE.turnOrder[i]; + for(uint8_t i = 0; i < BATTLE.executionCount; i++) { + if(BATTLE.fighters[BATTLE.executionOrder[i]].team == team) continue; + sorted[count++] = BATTLE.executionOrder[i]; } - memoryCopy(BATTLE.turnOrder, sorted, sizeof(uint8_t) * BATTLE.turnCount); + memoryCopy(BATTLE.executionOrder, sorted, sizeof(uint8_t) * BATTLE.executionCount); } battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter) { @@ -221,3 +234,93 @@ battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter) { return weakest; } + +void battleSetState(const battlestate_t next) { + const battlestate_t previous = BATTLE.state; + BATTLE.state = next; + if(BATTLE.onStateChanged != NULL) BATTLE.onStateChanged(previous, next); +} + +void battleSetResult(const battleresult_t result) { + BATTLE.result = result; + battleSetState(BATTLE_STATE_ENDED); +} + +bool_t battleFighterNeedsDecision( + const uint8_t fighterIndex, + const battlefightercontroller_t controller +) { + return battleFighterIsAlive(&BATTLE.fighters[fighterIndex]) + && BATTLE.fighters[fighterIndex].controller == controller + && BATTLE.actions[fighterIndex].type == BATTLE_ACTION_NONE; +} + +void battleAdvanceSelection(void) { + while(BATTLE.selectionIndex < BATTLE.executionCount) { + const uint8_t fighterIndex = BATTLE.executionOrder[BATTLE.selectionIndex]; + if( + battleFighterNeedsDecision(fighterIndex, BATTLE_FIGHTER_CONTROLLER_PLAYER) + ) { + return; + } + BATTLE.selectionIndex++; + } + + battleSetState(BATTLE_STATE_AI_SELECTION); +} + +void battleUpdatePreRound(void) { + // No need to clear BATTLE.actions here: every living fighter's action is + // unconditionally reset to BATTLE_ACTION_NONE as it's processed in + // battleUpdateMovesExecuting, and round 1 starts pre-zeroed by + // battleInit(). Clearing it here would also wipe out any action a + // cutscene force-queued while parked at BATTLE_STATE_PRE_ROUND. + battleBuildExecutionOrder(BATTLE.round == 1); + + BATTLE.selectionIndex = 0; + battleSetState(BATTLE_STATE_PLAYER_SELECTION); + battleAdvanceSelection(); +} + +void battleUpdateAiSelection(void) { + for(uint8_t i = 0; i < BATTLE.executionCount; i++) { + const uint8_t fighterIndex = BATTLE.executionOrder[i]; + if( + !battleFighterNeedsDecision(fighterIndex, BATTLE_FIGHTER_CONTROLLER_AI) + ) continue; + + battlefighter_t *target = + battleAIChooseTarget(&BATTLE.fighters[fighterIndex]); + if(target == NULL) continue; + + battleQueueAction(fighterIndex, BATTLE_ACTION_ATTACK, target->id); + } + + BATTLE.executionIndex = 0; + battleSetState(BATTLE_STATE_MOVES_EXECUTING); +} + +void battleUpdateMovesExecuting(void) { + if(BATTLE.executionIndex >= BATTLE.executionCount) { + battleSetState(BATTLE_STATE_POST_ROUND); + return; + } + + const uint8_t fighterIndex = BATTLE.executionOrder[BATTLE.executionIndex++]; + battlefighter_t *fighter = &BATTLE.fighters[fighterIndex]; + if(!battleFighterIsAlive(fighter)) return; + + battleaction_t *action = &BATTLE.actions[fighterIndex]; + if(action->type == BATTLE_ACTION_ATTACK) { + battlefighter_t *target = &BATTLE.fighters[action->targetIndex]; + if(battleFighterIsAlive(target)) battleResolveAttack(fighter, target); + } + action->type = BATTLE_ACTION_NONE; + + battleCheckResult(); +} + +void battleUpdatePostRound(void) { + BATTLE.round++; + battleSetState(BATTLE_STATE_PRE_ROUND); +} diff --git a/src/dusk/rpg/battle/battle.h b/src/dusk/rpg/battle/battle.h index 786a874e..bf5dad2c 100644 --- a/src/dusk/rpg/battle/battle.h +++ b/src/dusk/rpg/battle/battle.h @@ -27,19 +27,68 @@ typedef enum { BATTLE_RESULT_COUNT } battleresult_t; +// Where BATTLE currently is within a round. A cutscene can pause progression +// (CUTSCENE_PAUSE_BATTLE) and use CUTSCENE_BATTLE_WAIT_STATE to synchronize +// with any of these, or CUTSCENE_BATTLE_FORCE_ACTION to decide a fighter's +// action ahead of PLAYER_SELECTION/AI_SELECTION reaching them. +typedef enum { + BATTLE_STATE_NONE, // Battle inactive. + + BATTLE_STATE_OPENING, // Entered once by battleStart(). + BATTLE_STATE_PRE_ROUND, // Rebuilds execution order, clears the action queue. + BATTLE_STATE_PLAYER_SELECTION, // Waits on battlePlayerAttack/Flee. + BATTLE_STATE_AI_SELECTION, // Auto-queues every undecided AI fighter. + BATTLE_STATE_MOVES_EXECUTING, // Resolves one queued action per update. + BATTLE_STATE_POST_ROUND, // Round wrap-up; loops back to PRE_ROUND. + + BATTLE_STATE_ENDED, // Terminal for WIN/LOSS/FLED alike -- see BATTLE.result. + + BATTLE_STATE_COUNT +} battlestate_t; + +typedef enum { + BATTLE_ACTION_NONE, // No action decided yet for this fighter this round. + BATTLE_ACTION_ATTACK, + + BATTLE_ACTION_COUNT +} battleactiontype_t; + typedef struct { - bool_t active; + battleactiontype_t type; + uint8_t targetIndex; // Meaningful for BATTLE_ACTION_ATTACK. +} battleaction_t; + +typedef void (*battlestatechangedcallback_t)( + const battlestate_t previous, + const battlestate_t next +); + +typedef void (*battleactiondecidedcallback_t)( + const battlefighter_t *fighter, + const battleaction_t *action +); + +typedef struct { + battlestate_t state; battlefighter_t fighters[BATTLE_FIGHTER_COUNT_MAX]; + battleaction_t actions[BATTLE_FIGHTER_COUNT_MAX]; battleencountertype_t encounterType; bool_t fleeAvailable; battleresult_t result; // Fighter indices (into fighters[]), sorted for the current round. - uint8_t turnOrder[BATTLE_FIGHTER_COUNT_MAX]; - uint8_t turnCount; - uint8_t turnIndex; + uint8_t executionOrder[BATTLE_FIGHTER_COUNT_MAX]; + uint8_t executionCount; + uint8_t executionIndex; uint16_t round; + + // Cursor into executionOrder used by BATTLE_STATE_PLAYER_SELECTION to find + // the next player-controlled fighter that still needs a decision. + uint8_t selectionIndex; + + battlestatechangedcallback_t onStateChanged; + battleactiondecidedcallback_t onActionDecided; } battle_t; extern battle_t BATTLE; @@ -77,11 +126,10 @@ battlefighter_t *battleAddFighter( ); /** - * Starts the battle: builds the opening turn order (biased by - * encounterType for the first round only) and marks the battle active. - * Call once every fighter has been added via battleAddFighter. + * Starts the battle: enters BATTLE_STATE_OPENING and marks the battle + * active. Call once every fighter has been added via battleAddFighter. * - * @param encounterType Determines the opening round's turn order. + * @param encounterType Determines the opening round's execution order. * @param fleeAvailable Whether the party may attempt to flee this battle. */ void battleStart( @@ -95,10 +143,11 @@ void battleStart( void battleDispose(void); /** - * Returns the fighter whose turn it currently is. + * Returns the fighter currently awaiting a player decision. * - * @return Pointer to the active fighter, or NULL if the battle isn't - * active or has no living fighters left to act. + * @return Pointer to the fighter awaiting a decision, or NULL if the battle + * isn't in BATTLE_STATE_PLAYER_SELECTION or every player-controlled + * fighter has already decided. */ battlefighter_t *battleGetCurrentFighter(void); @@ -125,61 +174,68 @@ void battleResolveAttack( ); /** - * Ends the current fighter's turn and advances to the next fighter in - * the turn order, starting a new round (rebuilding turn order purely by - * speed) once every fighter in the current round has acted. - */ -void battleNextTurn(void); - -/** - * Checks whether the battle has been won or lost, updating and - * returning BATTLE.result. Does nothing if a result has already been - * set (e.g. by a successful flee). + * Checks whether the battle has been won or lost, transitioning to + * BATTLE_STATE_ENDED and updating BATTLE.result if so. Does nothing if a + * result has already been set (e.g. by a successful flee). * * @return The battle's current result. */ battleresult_t battleCheckResult(void); /** - * Submits the current fighter's attack against a target, if it is - * currently a player-controlled fighter's turn. Resolves the attack, - * checks for a battle result, and advances the turn. + * Queues an action for a fighter to perform once BATTLE_STATE_MOVES_EXECUTING + * reaches them this round, overwriting any action already queued for that + * fighter. Fires BATTLE.onActionDecided. + * + * @param fighterIndex Index into BATTLE.fighters of the deciding fighter. + * @param type The type of action to perform. + * @param targetIndex Index into BATTLE.fighters of the target, meaningful + * for BATTLE_ACTION_ATTACK. + */ +void battleQueueAction( + const uint8_t fighterIndex, + const battleactiontype_t type, + const uint8_t targetIndex +); + +/** + * Submits the currently-selecting fighter's attack against a target, if the + * battle is in BATTLE_STATE_PLAYER_SELECTION and awaiting a decision. + * Queues the action and advances the selection cursor. * * @param targetIndex Index into BATTLE.fighters of the target. */ void battlePlayerAttack(const uint8_t targetIndex); /** - * Submits a flee attempt for the current fighter's turn, if it is - * currently a player-controlled fighter's turn and fleeing is - * available for this battle. Always succeeds, ending the battle with - * BATTLE_RESULT_FLED. + * Submits a flee attempt for the currently-selecting fighter, if the battle + * is in BATTLE_STATE_PLAYER_SELECTION and fleeing is available for this + * battle. Always succeeds, ending the battle with BATTLE_RESULT_FLED. */ void battlePlayerFlee(void); /** - * Updates the battle simulation for one frame: resolves the current - * fighter's turn automatically if AI-controlled, otherwise waits for a - * player action via battlePlayerAttack/battlePlayerFlee. No-op if the - * battle isn't active or already has a result. + * Updates the battle simulation for one frame, dispatching on BATTLE.state. + * No-op if the battle isn't active, has already ended, or + * CUTSCENE_PAUSE_BATTLE is set. */ void battleUpdate(void); /** - * Rebuilds BATTLE.turnOrder/turnCount from every currently living + * Rebuilds BATTLE.executionOrder/executionCount from every currently living * fighter, sorted by speed descending. * * @param applyEncounterBias If true, reorders the freshly speed-sorted * queue so BATTLE.encounterType's favoured team goes first (used only * for the opening round). */ -void battleBuildTurnOrder(const bool_t applyEncounterBias); +void battleBuildExecutionOrder(const bool_t applyEncounterBias); /** - * Stably partitions BATTLE.turnOrder so every fighter on the given team + * Stably partitions BATTLE.executionOrder so every fighter on the given team * comes first, preserving each side's relative (speed-sorted) order. * - * @param team The team to move to the front of the turn order. + * @param team The team to move to the front of the execution order. */ void battleMoveTeamFirst(const battlefighterteam_t team); @@ -192,3 +248,66 @@ void battleMoveTeamFirst(const battlefighterteam_t team); * living fighters. */ battlefighter_t *battleAIChooseTarget(const battlefighter_t *fighter); + +/** + * Sets BATTLE.state and fires BATTLE.onStateChanged with the previous and + * new state. + * + * @param next The state to transition to. + */ +void battleSetState(const battlestate_t next); + +/** + * Sets BATTLE.result and transitions to BATTLE_STATE_ENDED. + * + * @param result The result to end the battle with. + */ +void battleSetResult(const battleresult_t result); + +/** + * Checks whether a fighter is a live, undecided candidate for the given + * controller -- i.e. whether PLAYER_SELECTION or AI_SELECTION should still + * be deciding an action for it this round. + * + * @param fighterIndex Index into BATTLE.fighters to check. + * @param controller The controller PLAYER_SELECTION/AI_SELECTION is + * currently deciding for. + * @return True if the fighter is alive, matches controller, and has no + * action queued yet. + */ +bool_t battleFighterNeedsDecision( + const uint8_t fighterIndex, + const battlefightercontroller_t controller +); + +/** + * Advances BATTLE.selectionIndex to the next player-controlled fighter that + * still needs a decision, or transitions to BATTLE_STATE_AI_SELECTION once + * none remain. + */ +void battleAdvanceSelection(void); + +/** + * Handles BATTLE_STATE_PRE_ROUND: rebuilds the execution order and moves on + * to BATTLE_STATE_PLAYER_SELECTION, positioning the selection cursor. + */ +void battleUpdatePreRound(void); + +/** + * Handles BATTLE_STATE_AI_SELECTION: queues an attack for every undecided + * AI-controlled fighter, then moves on to BATTLE_STATE_MOVES_EXECUTING. + */ +void battleUpdateAiSelection(void); + +/** + * Handles BATTLE_STATE_MOVES_EXECUTING: resolves one queued action from + * BATTLE.executionOrder per call, or transitions to BATTLE_STATE_POST_ROUND + * once every fighter this round has been processed. + */ +void battleUpdateMovesExecuting(void); + +/** + * Handles BATTLE_STATE_POST_ROUND: advances BATTLE.round and transitions + * back to BATTLE_STATE_PRE_ROUND. + */ +void battleUpdatePostRound(void); diff --git a/src/dusk/rpg/cutscene/cutscene.h b/src/dusk/rpg/cutscene/cutscene.h index f954455e..187eed25 100644 --- a/src/dusk/rpg/cutscene/cutscene.h +++ b/src/dusk/rpg/cutscene/cutscene.h @@ -162,6 +162,28 @@ typedef struct cutscene_s { .shake = { .amount = AMOUNT, .duration = DURATION } \ } +// Waits until BATTLE.state reaches STATE. Put this BEFORE +// CUTSCENE_SET_PAUSE(CUTSCENE_PAUSE_BATTLE), not after -- pausing first +// freezes BATTLE.state wherever it already is, so it would never reach +// STATE on its own to satisfy the wait. Waiting unpaused, then pausing the +// moment it's satisfied, catches the battle right at STATE before it can +// advance further. +#define CUTSCENE_BATTLE_WAIT_STATE(STATE) \ + { \ + .type = CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE, \ + .battleWaitState = { .state = STATE } \ + } + +// Immediately queues an attack for FIGHTER_INDEX against TARGET_INDEX, +// bypassing normal player/AI selection for that fighter this round. +#define CUTSCENE_BATTLE_FORCE_ACTION(FIGHTER_INDEX, TARGET_INDEX) \ + { \ + .type = CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION, \ + .battleForceAction = { \ + .fighterIndex = FIGHTER_INDEX, .targetIndex = TARGET_INDEX \ + } \ + } + #define CUTSCENE_SET_PAUSE(FLAGS) \ { .type = CUTSCENE_ITEM_TYPE_SET_PAUSE, .setPause = (FLAGS) } diff --git a/src/dusk/rpg/cutscene/cutscenepause.h b/src/dusk/rpg/cutscene/cutscenepause.h index 222d28c0..e9aad3fd 100644 --- a/src/dusk/rpg/cutscene/cutscenepause.h +++ b/src/dusk/rpg/cutscene/cutscenepause.h @@ -14,11 +14,13 @@ typedef uint8_t cutscenepause_t; #define CUTSCENE_PAUSE_NPC ((cutscenepause_t)(1 << 0)) #define CUTSCENE_PAUSE_PLAYER ((cutscenepause_t)(1 << 1)) #define CUTSCENE_PAUSE_WORLD ((cutscenepause_t)(1 << 2)) +#define CUTSCENE_PAUSE_BATTLE ((cutscenepause_t)(1 << 3)) #define CUTSCENE_PAUSE_DEFAULT ((cutscenepause_t)( \ CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_PLAYER \ )) #define CUTSCENE_PAUSE_ALL ((cutscenepause_t)( \ - CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_PLAYER | CUTSCENE_PAUSE_WORLD \ + CUTSCENE_PAUSE_NPC | CUTSCENE_PAUSE_PLAYER | CUTSCENE_PAUSE_WORLD | \ + CUTSCENE_PAUSE_BATTLE \ )) diff --git a/src/dusk/rpg/cutscene/item/battle/CMakeLists.txt b/src/dusk/rpg/cutscene/item/battle/CMakeLists.txt index 8c48be10..60ee1359 100644 --- a/src/dusk/rpg/cutscene/item/battle/CMakeLists.txt +++ b/src/dusk/rpg/cutscene/item/battle/CMakeLists.txt @@ -6,4 +6,6 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC cutscenestartbattle.c + cutscenebattlewaitstate.c + cutscenebattleforceaction.c ) diff --git a/src/dusk/rpg/cutscene/item/battle/cutscenebattleforceaction.c b/src/dusk/rpg/cutscene/item/battle/cutscenebattleforceaction.c new file mode 100644 index 00000000..c902430d --- /dev/null +++ b/src/dusk/rpg/cutscene/item/battle/cutscenebattleforceaction.c @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "rpg/cutscene/item/cutsceneitem.h" + +void cutsceneBattleForceActionStart( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + battleQueueAction( + item->battleForceAction.fighterIndex, BATTLE_ACTION_ATTACK, + item->battleForceAction.targetIndex + ); +} + +bool_t cutsceneBattleForceActionUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + return true; +} diff --git a/src/dusk/rpg/cutscene/item/battle/cutscenebattleforceaction.h b/src/dusk/rpg/cutscene/item/battle/cutscenebattleforceaction.h new file mode 100644 index 00000000..6dcef3e2 --- /dev/null +++ b/src/dusk/rpg/cutscene/item/battle/cutscenebattleforceaction.h @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "rpg/battle/battle.h" + +typedef struct { + uint8_t fighterIndex; + uint8_t targetIndex; +} cutscenebattleforceaction_t; + +typedef struct cutsceneitem_s cutsceneitem_t; +typedef union cutsceneitemdata_u cutsceneitemdata_t; + +/** + * Starts a battle force-action step: immediately queues an attack for the + * given fighter against the given target, bypassing normal player/AI + * selection for that fighter this round. + * + * @param item The cutscene item. + * @param data Runtime data storage. + */ +void cutsceneBattleForceActionStart( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); + +/** + * Updates a battle force-action step (always completes immediately). + * + * @param item The cutscene item. + * @param data Runtime data storage. + * @returns true always. + */ +bool_t cutsceneBattleForceActionUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); diff --git a/src/dusk/rpg/cutscene/item/battle/cutscenebattlewaitstate.c b/src/dusk/rpg/cutscene/item/battle/cutscenebattlewaitstate.c new file mode 100644 index 00000000..20ca60bf --- /dev/null +++ b/src/dusk/rpg/cutscene/item/battle/cutscenebattlewaitstate.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 cutsceneBattleWaitStateUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +) { + return BATTLE.state == item->battleWaitState.state; +} diff --git a/src/dusk/rpg/cutscene/item/battle/cutscenebattlewaitstate.h b/src/dusk/rpg/cutscene/item/battle/cutscenebattlewaitstate.h new file mode 100644 index 00000000..a780c5cc --- /dev/null +++ b/src/dusk/rpg/cutscene/item/battle/cutscenebattlewaitstate.h @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "rpg/battle/battle.h" + +typedef struct { + battlestate_t state; +} cutscenebattlewaitstate_t; + +typedef struct cutsceneitem_s cutsceneitem_t; +typedef union cutsceneitemdata_u cutsceneitemdata_t; + +/** + * Updates a battle wait-state step, completing once BATTLE.state reaches + * the watched state. Has no Start callback -- there's nothing to do until + * the state is actually reached. + * + * @param item The cutscene item. + * @param data Runtime data storage. + * @returns true once BATTLE.state equals the watched state. + */ +bool_t cutsceneBattleWaitStateUpdate( + const cutsceneitem_t *item, + cutsceneitemdata_t *data +); diff --git a/src/dusk/rpg/cutscene/item/cutsceneitem.c b/src/dusk/rpg/cutscene/item/cutsceneitem.c index 348de65f..85435fa1 100644 --- a/src/dusk/rpg/cutscene/item/cutsceneitem.c +++ b/src/dusk/rpg/cutscene/item/cutsceneitem.c @@ -118,6 +118,15 @@ cutsceneitemcallbacks_t CUTSCENE_ITEM_CALLBACKS[CUTSCENE_ITEM_TYPE_COUNT] = { [CUTSCENE_ITEM_TYPE_SHAKE] = { .init = cutsceneShakeStart, .update = cutsceneShakeUpdate + }, + + [CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE] = { + .update = cutsceneBattleWaitStateUpdate + }, + + [CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION] = { + .init = cutsceneBattleForceActionStart, + .update = cutsceneBattleForceActionUpdate } }; diff --git a/src/dusk/rpg/cutscene/item/cutsceneitem.h b/src/dusk/rpg/cutscene/item/cutsceneitem.h index 46ee3be1..0d39d6c7 100644 --- a/src/dusk/rpg/cutscene/item/cutsceneitem.h +++ b/src/dusk/rpg/cutscene/item/cutsceneitem.h @@ -27,6 +27,8 @@ #include "maparea/cutscenemaparearemove.h" #include "maparea/cutscenemapareawait.h" #include "battle/cutscenestartbattle.h" +#include "battle/cutscenebattlewaitstate.h" +#include "battle/cutscenebattleforceaction.h" typedef struct cutscene_s cutscene_t; @@ -55,6 +57,8 @@ typedef enum { CUTSCENE_ITEM_TYPE_START_BATTLE, CUTSCENE_ITEM_TYPE_EMOJI, CUTSCENE_ITEM_TYPE_SHAKE, + CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE, + CUTSCENE_ITEM_TYPE_BATTLE_FORCE_ACTION, CUTSCENE_ITEM_TYPE_COUNT } cutsceneitemtype_t; @@ -85,6 +89,8 @@ struct cutsceneitem_s { cutscenestartbattle_t startBattle; cutsceneemoji_t emoji; cutsceneshake_t shake; + cutscenebattlewaitstate_t battleWaitState; + cutscenebattleforceaction_t battleForceAction; }; }; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 226d9346..e512e723 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -8,7 +8,7 @@ add_subdirectory(asset) add_subdirectory(error) add_subdirectory(thread) add_subdirectory(display) -# add_subdirectory(rpg) +add_subdirectory(rpg) # add_subdirectory(item) add_subdirectory(time) add_subdirectory(util) \ No newline at end of file diff --git a/test/rpg/CMakeLists.txt b/test/rpg/CMakeLists.txt index 5a1133cd..97a798ee 100644 --- a/test/rpg/CMakeLists.txt +++ b/test/rpg/CMakeLists.txt @@ -9,4 +9,5 @@ include(dusktest) dusktest(test_rpg.c) # Subdirs -add_subdirectory(overworld) \ No newline at end of file +add_subdirectory(overworld) +add_subdirectory(battle) \ No newline at end of file diff --git a/test/rpg/battle/CMakeLists.txt b/test/rpg/battle/CMakeLists.txt new file mode 100644 index 00000000..b9d92cfd --- /dev/null +++ b/test/rpg/battle/CMakeLists.txt @@ -0,0 +1,9 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +include(dusktest) + +# Tests +dusktest(test_battle.c) diff --git a/test/rpg/battle/test_battle.c b/test/rpg/battle/test_battle.c new file mode 100644 index 00000000..34b8b88f --- /dev/null +++ b/test/rpg/battle/test_battle.c @@ -0,0 +1,443 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "dusktest.h" +#include "rpg/battle/battle.h" +#include "rpg/cutscene/cutscenesystem.h" + +// Fighter slots as added by test_battleCutsceneForceActionOverridesTarget: +// 0 = allyA, 1 = allyB, 2 = enemy. +static const cutsceneitem_t CUTSCENE_TEST_SCRATCH_ITEMS[] = { + // Waiting BEFORE pausing is the correct order: pausing first would + // freeze BATTLE.state wherever it happened to be and it would never + // reach PRE_ROUND on its own to satisfy the wait. + CUTSCENE_BATTLE_WAIT_STATE(BATTLE_STATE_PRE_ROUND), + CUTSCENE_SET_PAUSE(CUTSCENE_PAUSE_BATTLE), + CUTSCENE_BATTLE_FORCE_ACTION(2, 0),// enemy (slot 2) forced onto allyA (slot 0) + CUTSCENE_SET_PAUSE(CUTSCENE_PAUSE_NONE) +}; +static const cutscene_t CUTSCENE_TEST_SCRATCH = { + .items = CUTSCENE_TEST_SCRATCH_ITEMS, + .itemCount = sizeof(CUTSCENE_TEST_SCRATCH_ITEMS) / sizeof(cutsceneitem_t), + .pause = CUTSCENE_PAUSE_NONE, + .dataSize = 0 +}; + +static battlefighter_t *addFighter( + const battlefighterteam_t team, + const battlefightercontroller_t controller, + const uint16_t attack, + const uint16_t defense, + const uint16_t speed, + const uint16_t healthMax +) { + const battlefighterstats_t stats = { + .attack = attack, .defense = defense, .speed = speed + }; + return battleAddFighter(team, controller, stats, healthMax, 0); +} + +static void test_battleStartEntersOpeningThenPreRound(void **state) { + + + battleInit(); + addFighter(BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 10, 0, 10, 20); + addFighter(BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 10, 0, 5, 20); + + battleStart(BATTLE_ENCOUNTER_REGULAR, true); + assert_int_equal(BATTLE.state, BATTLE_STATE_OPENING); + + // OPENING only transitions to PRE_ROUND; it doesn't run PRE_ROUND's logic + // in the same call. + battleUpdate(); + assert_int_equal(BATTLE.state, BATTLE_STATE_PRE_ROUND); + assert_int_equal(BATTLE.executionCount, 0); + + // PRE_ROUND builds the execution order and moves on to selection. + battleUpdate(); + assert_int_equal(BATTLE.executionCount, 2); + assert_true( + BATTLE.state == BATTLE_STATE_PLAYER_SELECTION || + BATTLE.state == BATTLE_STATE_AI_SELECTION + ); +} + +static void test_battlePlayerSelectionAdvancesAndSkipsDecidedFighters( + void **state +) { + + + battleInit(); + battlefighter_t *ally1 = addFighter( + BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 100, 20 + ); + battlefighter_t *ally2 = addFighter( + BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 10, 20 + ); + battlefighter_t *enemy = addFighter( + BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 5, 0, 50, 20 + ); + + battleStart(BATTLE_ENCOUNTER_REGULAR, true); + battleUpdate();// OPENING -> PRE_ROUND + battleUpdate();// PRE_ROUND -> PLAYER_SELECTION, positions the cursor + + // ally1 is fastest, so it's first to decide. + assert_int_equal(BATTLE.state, BATTLE_STATE_PLAYER_SELECTION); + assert_ptr_equal(battleGetCurrentFighter(), ally1); + + // A forced action (as a scripted-battle cutscene item would push) marks + // ally2 as already decided, so PLAYER_SELECTION must skip it. + battleQueueAction(ally2->id, BATTLE_ACTION_ATTACK, enemy->id); + + battlePlayerAttack(enemy->id); + assert_int_equal(BATTLE.actions[ally1->id].type, BATTLE_ACTION_ATTACK); + assert_int_equal(BATTLE.actions[ally1->id].targetIndex, enemy->id); + + // ally2 already had a decision queued, and enemy is AI-controlled, so + // there's nothing left for PLAYER_SELECTION -- it should have moved on. + assert_int_equal(BATTLE.state, BATTLE_STATE_AI_SELECTION); +} + +static void test_battleAiSelectionAutoQueuesUndecidedAiFighters(void **state) { + + + battleInit(); + battlefighter_t *ally = addFighter( + BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 10, 20 + ); + battlefighter_t *enemy = addFighter( + BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 5, 0, 5, 20 + ); + + battleStart(BATTLE_ENCOUNTER_REGULAR, true); + battleUpdate();// OPENING -> PRE_ROUND + battleUpdate();// PRE_ROUND -> PLAYER_SELECTION + + battlePlayerAttack(enemy->id);// ally decides, selection moves to AI + assert_int_equal(BATTLE.state, BATTLE_STATE_AI_SELECTION); + + battleUpdate();// AI_SELECTION auto-decides for enemy + assert_int_equal(BATTLE.state, BATTLE_STATE_MOVES_EXECUTING); + assert_int_equal(BATTLE.actions[enemy->id].type, BATTLE_ACTION_ATTACK); + assert_int_equal(BATTLE.actions[enemy->id].targetIndex, ally->id); +} + +static void test_battleMovesExecutingResolvesInSpeedOrderAndFizzles( + void **state +) { + + + battleInit(); + // ally1 is fast enough to kill enemy1 before enemy1 or ally2 act. enemy2 + // keeps the battle alive so execution continues to ally2's now-moot + // attack against the already-dead enemy1. + battlefighter_t *ally1 = addFighter( + BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 50, 0, 100, 20 + ); + battlefighter_t *ally2 = addFighter( + BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 50, 0, 10, 20 + ); + battlefighter_t *enemy1 = addFighter( + BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 1, 0, 50, 10 + ); + battlefighter_t *enemy2 = addFighter( + BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 1, 0, 1, 100 + ); + + battleStart(BATTLE_ENCOUNTER_REGULAR, true); + battleUpdate();// OPENING -> PRE_ROUND + battleUpdate();// PRE_ROUND -> PLAYER_SELECTION (ally1 first, fastest) + + assert_ptr_equal(battleGetCurrentFighter(), ally1); + battlePlayerAttack(enemy1->id); + assert_ptr_equal(battleGetCurrentFighter(), ally2); + battlePlayerAttack(enemy1->id);// both allies target enemy1 + assert_int_equal(BATTLE.state, BATTLE_STATE_AI_SELECTION); + + battleUpdate();// AI_SELECTION + assert_int_equal(BATTLE.state, BATTLE_STATE_MOVES_EXECUTING); + + // Execution order: ally1 (100), enemy1 (50), ally2 (10), enemy2 (1). + battleUpdate();// ally1 kills enemy1 + assert_false(battleFighterIsAlive(enemy1)); + assert_int_equal(BATTLE.state, BATTLE_STATE_MOVES_EXECUTING);// enemy2 alive + + battleUpdate();// enemy1's own (now dead) turn is skipped + battleUpdate();// ally2's attack against the dead enemy1 fizzles silently + assert_int_equal(enemy1->health, 0);// no double-kill, no underflow + + battleUpdate();// enemy2 acts + battleUpdate();// all 4 slots processed -> POST_ROUND + assert_int_equal(BATTLE.state, BATTLE_STATE_POST_ROUND); + + battleUpdate();// POST_ROUND -> PRE_ROUND, round advances + assert_int_equal(BATTLE.round, 2); + assert_int_equal(BATTLE.state, BATTLE_STATE_PRE_ROUND); +} + +static void test_battleWinEndsInStateEnded(void **state) { + + + battleInit(); + battlefighter_t *ally = addFighter( + BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 50, 0, 10, 20 + ); + battlefighter_t *enemy = addFighter( + BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 1, 0, 5, 10 + ); + + battleStart(BATTLE_ENCOUNTER_REGULAR, true); + battleUpdate();// OPENING -> PRE_ROUND + battleUpdate();// PRE_ROUND -> PLAYER_SELECTION + + battlePlayerAttack(enemy->id);// selection -> AI_SELECTION + battleUpdate();// AI_SELECTION -> MOVES_EXECUTING + battleUpdate();// ally kills the only enemy + + assert_int_equal(BATTLE.state, BATTLE_STATE_ENDED); + assert_int_equal(BATTLE.result, BATTLE_RESULT_WIN); +} + +static void test_battleLossEndsInStateEnded(void **state) { + + + battleInit(); + battlefighter_t *ally = addFighter( + BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 1, 0, 5, 10 + ); + battlefighter_t *enemy = addFighter( + BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 50, 0, 10, 20 + ); + + battleStart(BATTLE_ENCOUNTER_REGULAR, true); + battleUpdate();// OPENING -> PRE_ROUND + battleUpdate();// PRE_ROUND -> PLAYER_SELECTION + + battlePlayerAttack(enemy->id);// selection -> AI_SELECTION + battleUpdate();// AI_SELECTION -> MOVES_EXECUTING + battleUpdate();// ally attacks first (irrelevant to outcome) + battleUpdate();// enemy kills the only ally + + assert_int_equal(BATTLE.state, BATTLE_STATE_ENDED); + assert_int_equal(BATTLE.result, BATTLE_RESULT_LOSS); +} + +static void test_battleFleeEndsInStateEndedImmediately(void **state) { + + + battleInit(); + addFighter(BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 10, 20); + addFighter(BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 5, 0, 5, 20); + + battleStart(BATTLE_ENCOUNTER_REGULAR, true); + battleUpdate();// OPENING -> PRE_ROUND + battleUpdate();// PRE_ROUND -> PLAYER_SELECTION + + battlePlayerFlee(); + assert_int_equal(BATTLE.state, BATTLE_STATE_ENDED); + assert_int_equal(BATTLE.result, BATTLE_RESULT_FLED); +} + +static void test_battleFleeUnavailableIsIgnored(void **state) { + + + battleInit(); + addFighter(BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 10, 20); + addFighter(BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 5, 0, 5, 20); + + battleStart(BATTLE_ENCOUNTER_REGULAR, false); + battleUpdate();// OPENING -> PRE_ROUND + battleUpdate();// PRE_ROUND -> PLAYER_SELECTION + + battlePlayerFlee(); + assert_int_equal(BATTLE.state, BATTLE_STATE_PLAYER_SELECTION); + assert_int_equal(BATTLE.result, BATTLE_RESULT_NONE); +} + +static uint8_t stateChangedCount; +static battlestate_t stateChangedLog[32][2]; + +static void recordStateChanged( + const battlestate_t previous, + const battlestate_t next +) { + if(stateChangedCount >= 32) return; + stateChangedLog[stateChangedCount][0] = previous; + stateChangedLog[stateChangedCount][1] = next; + stateChangedCount++; +} + +static void test_battleOnStateChangedFiresAcrossFullRound(void **state) { + + + battleInit(); + stateChangedCount = 0; + BATTLE.onStateChanged = recordStateChanged; + + addFighter(BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 1, 0, 10, 100); + battlefighter_t *enemy = addFighter( + BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 1, 0, 5, 100 + ); + + battleStart(BATTLE_ENCOUNTER_REGULAR, true);// fires NONE -> OPENING + battleUpdate();// OPENING -> PRE_ROUND + battleUpdate();// PRE_ROUND -> PLAYER_SELECTION + battlePlayerAttack(enemy->id);// PLAYER_SELECTION -> AI_SELECTION + battleUpdate();// AI_SELECTION -> MOVES_EXECUTING + battleUpdate();// MOVES_EXECUTING: ally's attack resolves (still executing) + battleUpdate();// MOVES_EXECUTING: enemy's attack resolves (still executing) + battleUpdate();// both slots processed -> POST_ROUND + battleUpdate();// POST_ROUND -> PRE_ROUND, round advances + + assert_int_equal(stateChangedLog[0][0], BATTLE_STATE_NONE); + assert_int_equal(stateChangedLog[0][1], BATTLE_STATE_OPENING); + assert_int_equal(stateChangedLog[1][1], BATTLE_STATE_PRE_ROUND); + assert_int_equal(stateChangedLog[2][1], BATTLE_STATE_PLAYER_SELECTION); + assert_int_equal(stateChangedLog[3][1], BATTLE_STATE_AI_SELECTION); + assert_int_equal(stateChangedLog[4][1], BATTLE_STATE_MOVES_EXECUTING); + assert_int_equal(stateChangedLog[5][1], BATTLE_STATE_POST_ROUND); + assert_int_equal(stateChangedLog[6][1], BATTLE_STATE_PRE_ROUND); + + BATTLE.onStateChanged = NULL; +} + +static uint8_t actionDecidedCount; + +static void recordActionDecided( + const battlefighter_t *fighter, + const battleaction_t *action +) { + actionDecidedCount++; +} + +static void test_battleOnActionDecidedFiresPerFighterRegardlessOfSource( + void **state +) { + + + battleInit(); + actionDecidedCount = 0; + BATTLE.onActionDecided = recordActionDecided; + + battlefighter_t *ally1 = addFighter( + BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 1, 0, 10, 100 + ); + battlefighter_t *ally2 = addFighter( + BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 1, 0, 9, 100 + ); + battlefighter_t *enemy = addFighter( + BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 1, 0, 5, 100 + ); + + battleStart(BATTLE_ENCOUNTER_REGULAR, true); + battleUpdate();// OPENING -> PRE_ROUND + battleUpdate();// PRE_ROUND -> PLAYER_SELECTION + + // A forced action, as CUTSCENE_BATTLE_FORCE_ACTION would push, counts too. + battleQueueAction(ally2->id, BATTLE_ACTION_ATTACK, enemy->id); + assert_int_equal(actionDecidedCount, 1); + + battlePlayerAttack(enemy->id);// ally1's real decision + assert_int_equal(actionDecidedCount, 2); + assert_int_equal(BATTLE.state, BATTLE_STATE_AI_SELECTION); + + battleUpdate();// AI_SELECTION decides for enemy + assert_int_equal(actionDecidedCount, 3); + + BATTLE.onActionDecided = NULL; +} + +// Exercises CUTSCENE_BATTLE_WAIT_STATE + CUTSCENE_SET_PAUSE(BATTLE) + +// CUTSCENE_BATTLE_FORCE_ACTION together, driving the cutscene and battle +// systems side by side the way rpg.c's main loop does, to prove a +// partially-scripted round actually works end-to-end (not just that each +// item type compiles). +static void test_battleCutsceneForceActionOverridesTarget(void **state) { + + + battleInit(); + cutsceneSystemInit(); + + battlefighter_t *allyA = addFighter( + BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 10, 20 + ); + battlefighter_t *allyB = addFighter( + BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER, 5, 0, 8, 20 + ); + battlefighter_t *enemy = addFighter( + BATTLE_FIGHTER_TEAM_ENEMY, BATTLE_FIGHTER_CONTROLLER_AI, 3, 0, 5, 100 + ); + assert_int_equal(allyA->id, 0); + assert_int_equal(allyB->id, 1); + assert_int_equal(enemy->id, 2); + + battleStart(BATTLE_ENCOUNTER_REGULAR, true); + cutsceneSystemStartCutscene(&CUTSCENE_TEST_SCRATCH); + + // Drive both systems together until BATTLE.state first reaches PRE_ROUND: + // cutscene sees it via CUTSCENE_BATTLE_WAIT_STATE and immediately pauses + // the battle before it can cascade on to PLAYER_SELECTION. + cutsceneSystemUpdate();// WAIT_STATE: still OPENING, not satisfied yet + battleUpdate();// OPENING -> PRE_ROUND + cutsceneSystemUpdate();// WAIT_STATE satisfied -> SET_PAUSE(BATTLE) applied + battleUpdate();// paused: no-op + + assert_int_equal(BATTLE.state, BATTLE_STATE_PRE_ROUND); + assert_true(CUTSCENE_SYSTEM.pause & CUTSCENE_PAUSE_BATTLE); + + cutsceneSystemUpdate();// SET_PAUSE done -> FORCE_ACTION queues enemy's move + battleUpdate();// still paused: no-op + + assert_int_equal(BATTLE.actions[enemy->id].type, BATTLE_ACTION_ATTACK); + assert_int_equal(BATTLE.actions[enemy->id].targetIndex, allyA->id); + assert_int_equal(BATTLE.state, BATTLE_STATE_PRE_ROUND);// still frozen + + cutsceneSystemUpdate();// FORCE_ACTION done -> SET_PAUSE(NONE) lifts it + battleUpdate();// unpaused: PRE_ROUND -> PLAYER_SELECTION, allyA first + + assert_false(CUTSCENE_SYSTEM.pause & CUTSCENE_PAUSE_BATTLE); + assert_int_equal(BATTLE.state, BATTLE_STATE_PLAYER_SELECTION); + assert_ptr_equal(battleGetCurrentFighter(), allyA); + // The forced action from three frames ago survived untouched. + assert_int_equal(BATTLE.actions[enemy->id].type, BATTLE_ACTION_ATTACK); + assert_int_equal(BATTLE.actions[enemy->id].targetIndex, allyA->id); + + battlePlayerAttack(enemy->id);// allyA -> AI_SELECTION would be next... + assert_ptr_equal(battleGetCurrentFighter(), allyB); + battlePlayerAttack(enemy->id);// ...but AI_SELECTION must skip the enemy, + assert_int_equal(BATTLE.state, BATTLE_STATE_AI_SELECTION);// since it's decided + + battleUpdate();// AI_SELECTION: enemy already decided, skipped -> MOVES_EXECUTING + assert_int_equal(BATTLE.actions[enemy->id].targetIndex, allyA->id);// untouched + + battleUpdate();// allyA attacks enemy + battleUpdate();// allyB attacks enemy + battleUpdate();// enemy attacks its forced target: allyA, not allyB + + assert_int_equal(enemy->health, 90);// 100 - 5 (allyA) - 5 (allyB) + assert_int_equal(allyA->health, 17);// 20 - 3 (enemy's forced attack) + assert_int_equal(allyB->health, 20);// never targeted -- proves the override +} + +int main(int argc, char** argv) { + const struct CMUnitTest tests[] = { + cmocka_unit_test(test_battleStartEntersOpeningThenPreRound), + cmocka_unit_test(test_battlePlayerSelectionAdvancesAndSkipsDecidedFighters), + cmocka_unit_test(test_battleAiSelectionAutoQueuesUndecidedAiFighters), + cmocka_unit_test(test_battleMovesExecutingResolvesInSpeedOrderAndFizzles), + cmocka_unit_test(test_battleWinEndsInStateEnded), + cmocka_unit_test(test_battleLossEndsInStateEnded), + cmocka_unit_test(test_battleFleeEndsInStateEndedImmediately), + cmocka_unit_test(test_battleFleeUnavailableIsIgnored), + cmocka_unit_test(test_battleOnStateChangedFiresAcrossFullRound), + cmocka_unit_test(test_battleOnActionDecidedFiresPerFighterRegardlessOfSource), + cmocka_unit_test(test_battleCutsceneForceActionOverridesTarget), + }; + + return cmocka_run_group_tests(tests, NULL, NULL); +}