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
+161 -58
View File
@@ -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);
}
+155 -36
View File
@@ -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);
+22
View File
@@ -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) }
+3 -1
View File
@@ -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 \
))
@@ -6,4 +6,6 @@
target_sources(${DUSK_LIBRARY_TARGET_NAME}
PUBLIC
cutscenestartbattle.c
cutscenebattlewaitstate.c
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;
}
@@ -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
);
@@ -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;
}
@@ -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
);
@@ -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
}
};
@@ -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;
};
};
+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);
}