/** * 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); }