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 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 08:49:42 -05:00
parent 3de50b8370
commit fb48285143
15 changed files with 925 additions and 97 deletions
+1 -1
View File
@@ -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)
+2 -1
View File
@@ -9,4 +9,5 @@ include(dusktest)
dusktest(test_rpg.c)
# Subdirs
add_subdirectory(overworld)
add_subdirectory(overworld)
add_subdirectory(battle)
+9
View File
@@ -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)
+443
View File
@@ -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);
}