Rework battle system: pointer-based fighters, per-state files, move split

Splits battlemoveaction_t into its own header, adds a per-state
struct/init/update/dispose file under rpg/battle/state driven by a
dispatch table, switches BATTLE.fighters to battlefighter_t* backed by a
pool, folds current/max health and mp into battlefighterstats_t, and adds
battleFighterDamage/Heal/AbilityAdd. Removes the now-unused battle
cutscene items (START_BATTLE, BATTLE_WAIT_STATE, BATTLE_FORCE_MOVE,
REGULAR_BATTLE) and their JSON lookup helpers, and adds JSON validation
to battleMoveInit. battle.h/battle.c are mid-rewrite around the new state
machine, so some UI consumers (uibattlehud.c, uibattlemenu.c) and
test_battle.c are currently out of sync with the new API.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-19 23:17:27 -05:00
co-authored by Claude Sonnet 5
parent d8273a225c
commit 3675ec634f
61 changed files with 878 additions and 1444 deletions
+2 -104
View File
@@ -7,36 +7,10 @@
#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,
@@ -110,7 +84,7 @@ static void test_battlePlayerSelectionAdvancesAndSkipsDecidedFighters(
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);
assert_int_equal(BATTLE.moves[ally1->id].targets[0], 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.
@@ -138,7 +112,7 @@ static void test_battleAiSelectionAutoQueuesUndecidedAiFighters(void **state) {
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);
assert_int_equal(BATTLE.moves[enemy->id].targets[0], ally->id);
}
static void test_battleMovesExecutingResolvesInSpeedOrderAndFizzles(
@@ -281,81 +255,6 @@ static void test_battleFleeUnavailableIsIgnored(void **state) {
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),
@@ -366,7 +265,6 @@ int main(int argc, char** argv) {
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);