374 lines
13 KiB
C
374 lines
13 KiB
C
/**
|
|
* 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"
|
|
|
|
// TEMPORARY: hardcoded pending a real moves.jsonc-backed registry lookup.
|
|
#define BATTLE_MOVE_ID_ATTACK ((battlemoveid_t)1)
|
|
|
|
// Fighter slots as added by test_battleCutsceneForceMoveOverridesTarget:
|
|
// 0 = allyA, 1 = allyB, 2 = enemy.
|
|
static 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.
|
|
{
|
|
.type = CUTSCENE_ITEM_TYPE_BATTLE_WAIT_STATE,
|
|
.battleWaitState = { .state = BATTLE_STATE_PRE_ROUND }
|
|
},
|
|
{ .type = CUTSCENE_ITEM_TYPE_SET_PAUSE, .setPause = CUTSCENE_PAUSE_BATTLE },
|
|
// enemy (slot 2) forced onto allyA (slot 0)
|
|
{
|
|
.type = CUTSCENE_ITEM_TYPE_BATTLE_FORCE_MOVE,
|
|
.battleForceMove = { .fighterIndex = 2, .targetIndex = 0 }
|
|
},
|
|
{ .type = CUTSCENE_ITEM_TYPE_SET_PAUSE, .setPause = CUTSCENE_PAUSE_NONE }
|
|
};
|
|
static cutscene_t CUTSCENE_TEST_SCRATCH = {
|
|
.items = CUTSCENE_TEST_SCRATCH_ITEMS,
|
|
.itemCount = sizeof(CUTSCENE_TEST_SCRATCH_ITEMS) / sizeof(cutsceneitem_t),
|
|
.itemsMax = sizeof(CUTSCENE_TEST_SCRATCH_ITEMS) / sizeof(cutsceneitem_t),
|
|
.pause = CUTSCENE_PAUSE_NONE
|
|
};
|
|
|
|
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 move (as a scripted-battle cutscene item would push) marks
|
|
// ally2 as already decided, so PLAYER_SELECTION must skip it.
|
|
battleQueueMove(ally2->id, BATTLE_MOVE_ID_ATTACK, enemy->id);
|
|
|
|
battlePlayerAttack(enemy->id);
|
|
assert_int_equal(BATTLE.moves[ally1->id].move, BATTLE_MOVE_ID_ATTACK);
|
|
assert_int_equal(BATTLE.moves[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.moves[enemy->id].move, BATTLE_MOVE_ID_ATTACK);
|
|
assert_int_equal(BATTLE.moves[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);
|
|
}
|
|
|
|
// Exercises CUTSCENE_BATTLE_WAIT_STATE + CUTSCENE_SET_PAUSE(BATTLE) +
|
|
// CUTSCENE_BATTLE_FORCE_MOVE 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_battleCutsceneForceMoveOverridesTarget(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_MOVE queues enemy's move
|
|
battleUpdate();// still paused: no-op
|
|
|
|
assert_int_equal(BATTLE.moves[enemy->id].move, BATTLE_MOVE_ID_ATTACK);
|
|
assert_int_equal(BATTLE.moves[enemy->id].targetIndex, allyA->id);
|
|
assert_int_equal(BATTLE.state, BATTLE_STATE_PRE_ROUND);// still frozen
|
|
|
|
cutsceneSystemUpdate();// FORCE_MOVE 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 move from three frames ago survived untouched.
|
|
assert_int_equal(BATTLE.moves[enemy->id].move, BATTLE_MOVE_ID_ATTACK);
|
|
assert_int_equal(BATTLE.moves[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,
|
|
// since it's decided
|
|
assert_int_equal(BATTLE.state, BATTLE_STATE_AI_SELECTION);
|
|
|
|
// AI_SELECTION: enemy already decided, skipped -> MOVES_EXECUTING
|
|
battleUpdate();
|
|
// untouched
|
|
assert_int_equal(BATTLE.moves[enemy->id].targetIndex, allyA->id);
|
|
|
|
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_battleCutsceneForceMoveOverridesTarget),
|
|
};
|
|
|
|
return cmocka_run_group_tests(tests, NULL, NULL);
|
|
}
|