SpaceQuakeHulk/Quake/ideas/turn_systems.c

1405 lines
54 KiB
C
Raw Permalink Normal View History

2026-03-24 10:46:22 -04:00
#include "rlc_turn_systems.h"
#include "rlc_components.h"
#include "rlc_equipment_spawner.h"
#include "rlc_items.h"
#include "rlc_log.h"
#include "rlc_fov.h"
#include "rlc_npc_ai.h"
#include "rlc_pathfinding.h"
#include "rlc_turn_manager.h"
#include "rlc_tween.h"
#include "rlc_tween_components.h"
#include "dungeon_builder.h"
#include <stdlib.h>
/* ============================================================
* HELPER FUNCTIONS
* ============================================================ */
/* Forward declarations */
static bool is_tile_occupied(ecs_world_t *world, int x, int y, ecs_entity_t ignore);
/* Constants for Action Costs */
#define ATTACK_DELAY_BASE 20
/* Helper: Calculate attack delay */
static int calculate_attack_delay(int speed) {
/* Higher speed = lower delay */
/* Base 20, reduce by speed/5. Min delay 5. */
int delay = ATTACK_DELAY_BASE - (speed / 5);
if (delay < 5) delay = 5;
return delay;
}
/* Helper: Find free inventory slot */
static int find_free_slot(ecs_world_t *world, ecs_entity_t player) {
bool occupied[12] = {false};
ecs_query_t *q = ecs_query(world, {
.terms = {{.id = ecs_id(InventorySlot)},
{.first.id = ContainedBy, .second.id = player}}
});
ecs_iter_t it = ecs_query_iter(world, q);
while (ecs_query_next(&it)) {
InventorySlot *slots = ecs_field(&it, InventorySlot, 0);
for (int i = 0; i < it.count; i++) {
if (slots[i].slot_index >= 0 && slots[i].slot_index < 12) {
occupied[slots[i].slot_index] = true;
}
}
}
ecs_query_fini(q);
for (int i = 0; i < 12; i++) {
if (!occupied[i]) return i;
}
return -1;
}
/* Helper: Unequip item of specific type */
static void unequip_slot_type(ecs_world_t *world, ecs_entity_t player,
ecs_entity_t tag) {
ecs_query_t *q = ecs_query(world, {
.terms = {{.id = tag},
{.first.id = ContainedBy, .second.id = player}}
});
ecs_iter_t it = ecs_query_iter(world, q);
while (ecs_query_next(&it)) {
for (int i = 0; i < it.count; i++) {
ecs_remove_id(world, it.entities[i], tag);
}
}
ecs_query_fini(q);
}
/* Helper: Execute Immediate Movement */
static bool execute_move_immediate(ecs_world_t *world, ecs_entity_t actor, int dx, int dy) {
GridPosition *grid = ecs_get_mut(world, actor, GridPosition);
if (!grid) return false;
int new_x = grid->x + dx;
int new_y = grid->y + dy;
DungeonContext *dungeon = (DungeonContext *)ecs_get_ctx(world);
if (!dungeon) return false;
Tile t = dungeon_context_get_tile(dungeon, new_x, new_y);
if (t != TILE_FLOOR) {
/* Blocked by wall */
return false;
}
/* Check for entity collision */
if (is_tile_occupied(world, new_x, new_y, actor)) {
return false;
}
/* Move successful */
grid->x = new_x;
grid->y = new_y;
ecs_modified(world, actor, GridPosition);
RlcFOV *fov = ecs_get_mut(world, actor, RlcFOV);
if (fov) {
fov->needs_update = true;
ecs_modified(world, actor, RlcFOV);
}
bool is_player = ecs_has(world, actor, Player);
/* Update camera if player */
if (is_player) {
ecs_query_t *q = ecs_query(world, {
.terms = {{ .id = ecs_id(Camera) }}
});
if (!q) return true;
ecs_iter_t cam_it = ecs_query_iter(world, q);
while (ecs_query_next(&cam_it)) {
Camera *cam = ecs_field(&cam_it, Camera, 0);
if (cam && cam[0].follow_player) {
cam[0].target_x = (float)new_x;
cam[0].target_y = (float)new_y;
}
ecs_iter_fini(&cam_it); /* Required for early exit */
break;
}
ecs_query_fini(q);
}
return true;
}
/* Helper: Find equipped weapon entity for an actor */
static ecs_entity_t find_equipped_weapon(ecs_world_t *world, ecs_entity_t actor) {
ecs_query_t *q = ecs_query(world, {
.terms = {
{ .id = ecs_id(EquippedWeapon) },
{ .first.id = ContainedBy, .second.id = actor }
}
});
if (!q) return 0;
ecs_entity_t weapon = 0;
ecs_iter_t it = ecs_query_iter(world, q);
if (ecs_query_next(&it)) {
weapon = it.entities[0];
ecs_iter_fini(&it);
}
ecs_query_fini(q);
return weapon;
}
/* Helper: Find equipped armor entity for an actor */
static ecs_entity_t find_equipped_armor(ecs_world_t *world, ecs_entity_t actor) {
ecs_query_t *q = ecs_query(world, {
.terms = {
{ .id = ecs_id(EquippedArmor) },
{ .first.id = ContainedBy, .second.id = actor }
}
});
if (!q) return 0;
ecs_entity_t armor = 0;
ecs_iter_t it = ecs_query_iter(world, q);
if (ecs_query_next(&it)) {
armor = it.entities[0];
ecs_iter_fini(&it);
}
ecs_query_fini(q);
return armor;
}
/* Helper: Queue Attack — uses weapon delay if equipped, else unarmed default */
static void queue_attack(ecs_world_t *world, TurnState *ts,
ecs_entity_t actor, ecs_entity_t target, int speed) {
int delay = 15; /* unarmed default */
ecs_entity_t weapon = find_equipped_weapon(world, actor);
if (weapon) {
const WeaponStats *wpn = ecs_get(world, weapon, WeaponStats);
if (wpn) delay = wpn->delay;
} else {
/* Fallback: speed-based delay for monsters without equipment */
delay = calculate_attack_delay(speed);
}
QueuedAction qa = {
.action_type = ACTION_ATTACK,
.actor = actor,
.target = target,
.dx = 0,
.dy = 0,
.delay = delay,
.priority = 100
};
rlc_action_queue_push(ts, qa);
}
/* Check if any entities have active tweens (animations in progress)
* Returns true if any tween components exist in the world.
* Tweens auto-remove on completion, so existence = active animation. */
static bool has_active_tweens(ecs_world_t *world) {
/* Count entities with any tween component type */
int tween_pos_count = ecs_count(world, TweenPosition);
int tween_alpha_count = ecs_count(world, TweenAlpha);
int tween_scale_count = ecs_count(world, TweenScale);
int tween_offset_count = ecs_count(world, TweenOffset);
int total_tweens = tween_pos_count + tween_alpha_count +
tween_scale_count + tween_offset_count;
return total_tweens > 0;
}
/* System 1: Turn Initialization - Process delayed events */
static void TurnInitSystem(ecs_iter_t *it) {
TurnState *ts = ecs_field(it, TurnState, 0);
for (int i = 0; i < it->count; i++) {
/* Process delayed event queue */
rlc_event_queue_process(it->world, &ts[i]);
}
}
/* System 2: Timeline System - The Heartbeat of the Grand Integer System
* Advances time until an entity is ready to act. */
static void TimelineSystem(ecs_iter_t *it) {
/* Stop if visual effects (tweens) are playing */
if (has_active_tweens(it->world)) {
return;
}
/* Stop if anyone currently has the MyTurn tag (waiting for them to act) */
if (ecs_count(it->world, MyTurn) > 0) {
return;
}
/* Get all entities with Energy */
ecs_query_t *q_energy = ecs_query(it->world, {
.terms = {
{ .id = ecs_id(Energy) },
{ .id = ecs_id(Actor) } /* Only consider actors */
}
});
if (!q_energy) return;
/* Max ticks to simulate per frame to prevent infinite loops if no one can act */
int max_ticks = 1000;
int ticks = 0;
while (ticks < max_ticks) {
/* 1. Check if anyone has enough energy to act RIGHT NOW */
int actors_ready = 0;
ecs_iter_t energy_it = ecs_query_iter(it->world, q_energy);
while (ecs_query_next(&energy_it)) {
Energy *e = ecs_field(&energy_it, Energy, 0);
for (int i = 0; i < energy_it.count; i++) {
if (e[i].accumulator >= ACTION_COST) {
actors_ready++;
/* Mark them as "MyTurn" */
ecs_add(it->world, energy_it.entities[i], MyTurn);
}
}
}
/* 2. If we found someone, STOP. Let ECS systems run their logic. */
if (actors_ready > 0) {
ecs_query_fini(q_energy);
return;
}
/* 3. If no one is ready, advance the clock (Tick) */
/* This is the "Empty Time" where everyone regenerates */
energy_it = ecs_query_iter(it->world, q_energy);
while (ecs_query_next(&energy_it)) {
Energy *e = ecs_field(&energy_it, Energy, 0);
for (int i = 0; i < energy_it.count; i++) {
/* Apply interactions here! (e.g. status effects) */
int current_gain = e[i].gain_per_tick;
/* Example: if (ecs_has(it->world, energy_it.entities[i], StatusFrozen)) current_gain /= 2; */
e[i].accumulator += current_gain;
}
}
ticks++;
}
ecs_query_fini(q_energy);
}
/* System 3: Player Path Follow - Auto-follow mouse path when no keyboard input */
static void PlayerPathFollowSystem(ecs_iter_t *it) {
ActionIntent *intent = ecs_field(it, ActionIntent, 0);
AStarPath *path = ecs_field(it, AStarPath, 1);
PathStatus *status = ecs_field(it, PathStatus, 2);
/* Only run if player has turn */
if (!ecs_has(it->world, it->entities[0], MyTurn)) return;
for (int i = 0; i < it->count; i++) {
/* Skip if keyboard input is already pending */
if (intent[i].pending) continue;
/* Check if we have a valid path to follow */
if (status[i].value != PATH_VALID || rlc_path_is_complete(path + i)) {
continue;
}
/* Get next step from path */
int dx, dy;
if (rlc_path_get_next_step(path + i, &dx, &dy)) {
/* Set intent to move along path */
intent[i].action_type = ACTION_MOVE;
intent[i].dx = dx;
intent[i].dy = dy;
intent[i].target = 0;
intent[i].energy_cost = ACTION_COST_MOVE;
intent[i].pending = true;
/* Advance path for next turn */
rlc_path_advance(path + i);
/* Clear path if complete */
if (rlc_path_is_complete(path + i)) {
status[i].value = PATH_NONE;
}
// RLC_LOG_DEBUG("Player following path: (%d, %d)", dx, dy);
ecs_modified(it->world, it->entities[i], ActionIntent);
}
}
}
/* System 4: Player Action System - Handle player input */
static void PlayerActionSystem(ecs_iter_t *it) {
ActionIntent *intent = ecs_field(it, ActionIntent, 0);
Energy *energy = ecs_field(it, Energy, 1);
Actor *actor = ecs_field(it, Actor, 2);
TurnState *ts = ecs_singleton_get_mut(it->world, TurnState);
if (!ts) return;
for (int i = 0; i < it->count; i++) {
/* Ensure player has MyTurn tag */
if (!ecs_has(it->world, it->entities[i], MyTurn)) continue;
if (!intent[i].pending) continue;
/* If moving, check for Bump Attack */
if (intent[i].action_type == ACTION_MOVE) {
GridPosition *pos = ecs_get_mut(it->world, it->entities[i], GridPosition);
if (pos) {
int target_x = pos->x + intent[i].dx;
int target_y = pos->y + intent[i].dy;
/* Check if blocked by entity */
if (is_tile_occupied(it->world, target_x, target_y, it->entities[i])) {
/* Find WHO is blocking to see if we should attack */
ecs_query_t *q = ecs_query(it->world, {
.terms = {
{ .id = ecs_id(GridPosition) },
{ .id = ecs_id(Health) }, /* Only attack things with health */
{ .id = ecs_id(Actor) }
}
});
if (q) {
ecs_iter_t qit = ecs_query_iter(it->world, q);
while (ecs_query_next(&qit)) {
GridPosition *gp = ecs_field(&qit, GridPosition, 0);
for (int k = 0; k < qit.count; k++) {
if (qit.entities[k] != it->entities[i] && gp[k].x == target_x && gp[k].y == target_y) {
/* Found target! Convert to Attack */
intent[i].action_type = ACTION_ATTACK;
intent[i].target = qit.entities[k];
ecs_iter_fini(&qit); /* Required for early exit via goto */
goto found_blocker;
}
}
}
found_blocker:
ecs_query_fini(q);
}
}
}
}
/* Execute Action */
bool action_taken = false;
if (intent[i].action_type == ACTION_MOVE) {
/* Try to move */
bool moved = execute_move_immediate(it->world, it->entities[i], intent[i].dx, intent[i].dy);
if (moved) action_taken = true;
}
else if (intent[i].action_type == ACTION_ATTACK) {
/* Schedule Attack */
queue_attack(it->world, ts, it->entities[i], intent[i].target, actor[i].speed);
action_taken = true;
}
else if (intent[i].action_type == ACTION_WAIT) {
/* Explicit wait counts as taking a turn */
action_taken = true;
}
else {
/* Other actions (Use Item, etc) */
QueuedAction qa = {
.action_type = intent[i].action_type,
.actor = it->entities[i],
.target = intent[i].target,
.item = intent[i].item,
.dx = intent[i].dx,
.dy = intent[i].dy,
.delay = 0,
.priority = 50
};
rlc_action_queue_push(ts, qa);
action_taken = true;
}
if (action_taken) {
/* Pay the cost */
energy[i].accumulator -= ACTION_COST;
/* Remove MyTurn tag - turn ends */
ecs_remove(it->world, it->entities[i], MyTurn);
}
/* Clear intent */
intent[i].pending = false;
}
}
/* --- Helper: Find player entity and position --- */
static bool find_player(ecs_world_t *world, ecs_entity_t *out_player, int *out_x, int *out_y) {
ecs_query_t *player_q = ecs_query(world, {
.terms = {
{ .id = ecs_id(GridPosition) },
{ .id = ecs_id(Player) }
}
});
if (!player_q) return false;
ecs_iter_t player_it = ecs_query_iter(world, player_q);
bool found = false;
if (ecs_query_next(&player_it)) {
*out_player = player_it.entities[0];
const GridPosition *player_grid = ecs_field(&player_it, GridPosition, 0);
if (player_grid) {
*out_x = player_grid->x;
*out_y = player_grid->y;
found = true;
}
ecs_iter_fini(&player_it); /* Must finalize iterator before query_fini */
}
ecs_query_fini(player_q);
return found;
}
/* --- Helper: Check if any actor occupies a grid position --- */
static bool is_tile_occupied(ecs_world_t *world, int x, int y, ecs_entity_t ignore) {
ecs_query_t *q = ecs_query(world, {
.terms = {
{ .id = ecs_id(GridPosition) },
{ .id = ecs_id(Actor) }
}
});
if (!q) return false;
bool occupied = false;
ecs_iter_t qit = ecs_query_iter(world, q);
while (ecs_query_next(&qit)) {
GridPosition *gp = ecs_field(&qit, GridPosition, 0);
for (int i = 0; i < qit.count; i++) {
if (qit.entities[i] != ignore && gp[i].x == x && gp[i].y == y) {
occupied = true;
break;
}
}
if (occupied) {
ecs_iter_fini(&qit); /* Required for early exit */
break;
}
}
ecs_query_fini(q);
return occupied;
}
/* --- Helper: Check line-of-sight using Bresenham's line algorithm --- */
static bool has_line_of_sight(DungeonContext *dungeon, int x0, int y0, int x1, int y1) {
if (!dungeon) return false;
int dx = abs(x1 - x0);
int dy = abs(y1 - y0);
int sx = (x0 < x1) ? 1 : -1;
int sy = (y0 < y1) ? 1 : -1;
int err = dx - dy;
int x = x0, y = y0;
while (1) {
/* Don't check the start or end positions - only tiles in between */
if ((x != x0 || y != y0) && (x != x1 || y != y1)) {
Tile t = dungeon_context_get_tile(dungeon, x, y);
if (t == TILE_WALL || t == TILE_EMPTY) {
return false; /* Blocked by wall or void */
}
}
if (x == x1 && y == y1) break;
int e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x += sx;
}
if (e2 < dx) {
err += dx;
y += sy;
}
}
return true;
}
/* --- Helper: Check if entity can see target (range + line-of-sight) --- */
static bool can_see_entity(ecs_world_t *world, ecs_entity_t viewer, ecs_entity_t target) {
const GridPosition *viewer_pos = ecs_get(world, viewer, GridPosition);
const GridPosition *target_pos = ecs_get(world, target, GridPosition);
if (!viewer_pos || !target_pos) return false;
/* First: range check (early out if too far) */
const int MONSTER_PERCEPTION_RANGE = 8;
int dx = target_pos->x - viewer_pos->x;
int dy = target_pos->y - viewer_pos->y;
int dist_sq = dx * dx + dy * dy;
if (dist_sq > (MONSTER_PERCEPTION_RANGE * MONSTER_PERCEPTION_RANGE)) {
return false;
}
/* Second: line-of-sight check (can't see through walls).
* Use the world context DungeonContext* directly it's fully populated
* for all dungeon types (physics, BSP, loaded/imported, town, arena). */
DungeonContext *ctx = (DungeonContext *)ecs_get_ctx(world);
if (!ctx) {
return false;
}
return has_line_of_sight(ctx,
viewer_pos->x, viewer_pos->y,
target_pos->x, target_pos->y);
}
/* System 5a: AI Perception System - Update memory based on what AI can see */
static void AiPerceptionSystem(ecs_iter_t *it) {
Actor *actor = ecs_field(it, Actor, 0);
AiMemory *memory = ecs_field(it, AiMemory, 1);
GridPosition *grid = ecs_field(it, GridPosition, 2);
(void)actor;
/* AI Systems now run whenever the AI entity has MyTurn */
/* This system updates memory for *all* AIs though? */
/* No, we should only update for AIs that are about to act or we update all per frame? */
/* Better to update only for the active entity to save perf, or all? */
/* Actually, perception is a passive thing. But in a strict turn system, */
/* it matters when they act. Let's filter by MyTurn for now to keep it simple. */
/* However, to catch "I see you", we usually check perception. */
/* Let's iterate only MyTurn entities */
// Note: The original system iterated all Actors.
// We only want to process the entity whose turn it is.
// So we add MyTurn to the query in registration.
ecs_entity_t player = 0;
int player_x = 0, player_y = 0;
if (!find_player(it->world, &player, &player_x, &player_y)) return;
for (int i = 0; i < it->count; i++) {
ecs_entity_t monster = it->entities[i];
bool sees_player = can_see_entity(it->world, monster, player);
if (sees_player) {
memory[i].last_seen_player_x = player_x;
memory[i].last_seen_player_y = player_y;
memory[i].turns_since_player_seen = 0;
/* Update hunting state if present */
AiStateHunting *hunting = ecs_get_mut(it->world, monster, AiStateHunting);
if (hunting) {
hunting->last_known_x = player_x;
hunting->last_known_y = player_y;
hunting->target_visible = true;
}
} else {
memory[i].turns_since_player_seen++;
/* Mark target not visible in hunting state */
AiStateHunting *hunting = ecs_get_mut(it->world, monster, AiStateHunting);
if (hunting) {
hunting->target_visible = false;
}
}
(void)grid;
}
}
/* System 5b: AI State Transition System */
static void AiStateTransitionSystem(ecs_iter_t *it) {
Actor *actor = ecs_field(it, Actor, 0);
AiMemory *memory = ecs_field(it, AiMemory, 1);
(void)actor;
ecs_entity_t player = 0;
int player_x = 0, player_y = 0;
find_player(it->world, &player, &player_x, &player_y);
ecs_defer_begin(it->world);
for (int i = 0; i < it->count; i++) {
ecs_entity_t monster = it->entities[i];
bool sees_player = (memory[i].turns_since_player_seen == 0);
/* Check current state and decide transitions */
bool is_idle = ecs_has(it->world, monster, AiStateIdle);
bool is_wandering = ecs_has(it->world, monster, AiStateWandering);
bool is_hunting = ecs_has(it->world, monster, AiStateHunting);
bool is_investigating = ecs_has(it->world, monster, AiStateInvestigating);
bool is_fleeing = ecs_has(it->world, monster, AiStateFleeing);
/* Check health for fleeing decision */
const Health *hp = ecs_get(it->world, monster, Health);
bool low_health = hp && hp->max > 0 && (hp->current * 100 / hp->max) < 30;
/* Idle -> Hunting: Monster awakens when it sees the player */
if (is_idle) {
if (sees_player && player) {
ecs_remove(it->world, monster, AiStateIdle);
ecs_set(it->world, monster, AiStateHunting, {
.target = player,
.last_known_x = memory[i].last_seen_player_x,
.last_known_y = memory[i].last_seen_player_y,
.target_visible = true,
.stuck_turns = 0
});
RLC_LOG_DEBUG("Monster %lu: Idle -> Hunting (awakened!)", (unsigned long)monster);
continue;
}
continue;
}
if (is_wandering) {
if (sees_player && player) {
ecs_remove(it->world, monster, AiStateWandering);
ecs_set(it->world, monster, AiStateHunting, {
.target = player,
.last_known_x = memory[i].last_seen_player_x,
.last_known_y = memory[i].last_seen_player_y,
.target_visible = true,
.stuck_turns = 0
});
}
}
else if (is_hunting) {
AiStateHunting *hunting = ecs_get_mut(it->world, monster, AiStateHunting);
if (low_health) {
ecs_remove(it->world, monster, AiStateHunting);
ecs_add(it->world, monster, AiStateFleeing);
}
else if (hunting && !hunting->target_visible && memory[i].turns_since_player_seen >= 2) {
int target_x = hunting->last_known_x;
int target_y = hunting->last_known_y;
ecs_remove(it->world, monster, AiStateHunting);
ecs_set(it->world, monster, AiStateInvestigating, {
.target_x = target_x,
.target_y = target_y,
.turns_remaining = 5
});
}
else if (hunting && hunting->stuck_turns >= 3) {
ecs_remove(it->world, monster, AiStateHunting);
ecs_set(it->world, monster, AiStateInvestigating, {
.target_x = hunting->last_known_x,
.target_y = hunting->last_known_y,
.turns_remaining = 3
});
}
}
else if (is_investigating) {
AiStateInvestigating *inv = ecs_get_mut(it->world, monster, AiStateInvestigating);
if (sees_player && player) {
ecs_remove(it->world, monster, AiStateInvestigating);
ecs_set(it->world, monster, AiStateHunting, {
.target = player,
.last_known_x = memory[i].last_seen_player_x,
.last_known_y = memory[i].last_seen_player_y,
.target_visible = true,
.stuck_turns = 0
});
}
else if (inv && inv->turns_remaining <= 0) {
ecs_remove(it->world, monster, AiStateInvestigating);
ecs_add(it->world, monster, AiStateWandering);
}
}
else if (is_fleeing) {
if (memory[i].turns_since_player_seen >= 5) {
ecs_remove(it->world, monster, AiStateFleeing);
ecs_add(it->world, monster, AiStateWandering);
}
}
else {
ecs_add(it->world, monster, AiStateWandering);
}
}
ecs_defer_end(it->world);
}
/* System 5c: AI Action - Idle behavior */
static void AiActionIdleSystem(ecs_iter_t *it) {
Energy *energy = ecs_field(it, Energy, 1);
for (int i = 0; i < it->count; i++) {
/* Idle monsters pass their turn */
energy[i].accumulator -= ACTION_COST;
ecs_remove(it->world, it->entities[i], MyTurn);
}
}
/* System 5d: AI Action - Wandering behavior */
static void AiActionWanderingSystem(ecs_iter_t *it) {
Energy *energy = ecs_field(it, Energy, 1);
GridPosition *grid = ecs_field(it, GridPosition, 2);
DungeonContext *dungeon = (DungeonContext *)ecs_get_ctx(it->world);
if (!dungeon) return;
for (int i = 0; i < it->count; i++) {
ecs_entity_t monster = it->entities[i];
/* Pick random adjacent walkable tile */
int dx = 0, dy = 0;
int directions[8][2] = {{-1,-1},{0,-1},{1,-1},{-1,0},{1,0},{-1,1},{0,1},{1,1}};
for (int tries = 0; tries < 8; tries++) {
int dir_idx = rand() % 8;
int try_dx = directions[dir_idx][0];
int try_dy = directions[dir_idx][1];
int new_x = grid[i].x + try_dx;
int new_y = grid[i].y + try_dy;
Tile t = dungeon_context_get_tile(dungeon, new_x, new_y);
if (t == TILE_FLOOR) {
dx = try_dx;
dy = try_dy;
break;
}
}
if (dx != 0 || dy != 0) {
execute_move_immediate(it->world, monster, dx, dy);
}
/* Pay cost */
energy[i].accumulator -= ACTION_COST;
ecs_remove(it->world, monster, MyTurn);
}
}
/* System 5e: AI Action - Hunting behavior */
static void AiActionHuntingSystem(ecs_iter_t *it) {
Energy *energy = ecs_field(it, Energy, 1);
GridPosition *grid = ecs_field(it, GridPosition, 2);
AiStateHunting *hunting = ecs_field(it, AiStateHunting, 3);
Actor *actor = ecs_field(it, Actor, 4);
TurnState *ts = ecs_singleton_get_mut(it->world, TurnState);
if (!ts) return;
DungeonContext *dungeon = (DungeonContext *)ecs_get_ctx(it->world);
if (!dungeon) return;
ecs_entity_t player = 0;
int player_x = 0, player_y = 0;
find_player(it->world, &player, &player_x, &player_y);
for (int i = 0; i < it->count; i++) {
ecs_entity_t monster = it->entities[i];
if (!ecs_is_alive(it->world, hunting[i].target)) {
energy[i].accumulator -= ACTION_COST;
ecs_remove(it->world, monster, MyTurn);
continue;
}
int target_x = hunting[i].last_known_x;
int target_y = hunting[i].last_known_y;
int gx = grid[i].x;
int gy = grid[i].y;
int dist_x = player_x - gx;
int dist_y = player_y - gy;
bool adjacent = (abs(dist_x) <= 1 && abs(dist_y) <= 1 && (dist_x != 0 || dist_y != 0));
bool acted = false;
/* 1. Attack if adjacent */
if (adjacent && hunting[i].target_visible) {
queue_attack(it->world, ts, monster, player, actor[i].speed);
acted = true;
}
else {
/* 2. Move */
AStarPath *path = ecs_get_mut(it->world, monster, AStarPath);
PathStatus *status = ecs_get_mut(it->world, monster, PathStatus);
int dx = 0, dy = 0;
bool moved = false;
if (path && status && status->value == PATH_VALID && !rlc_path_is_complete(path)) {
int step_dx, step_dy;
if (rlc_path_get_next_step(path, &step_dx, &step_dy)) {
int new_x = grid[i].x + step_dx;
int new_y = grid[i].y + step_dy;
Tile t = dungeon_context_get_tile(dungeon, new_x, new_y);
if (t == TILE_FLOOR) {
dx = step_dx;
dy = step_dy;
rlc_path_advance(path);
moved = execute_move_immediate(it->world, monster, dx, dy);
if (moved) {
hunting[i].stuck_turns = 0;
acted = true;
}
}
}
}
if (!moved) {
int dist_sq = (target_x - grid[i].x) * (target_x - grid[i].x) +
(target_y - grid[i].y) * (target_y - grid[i].y);
if (dist_sq <= 400) {
rlc_request_path_to_entity(it->world, monster, hunting[i].target);
}
hunting[i].stuck_turns++;
/* Pass turn even if stuck to prevent freeze */
acted = true;
} else {
/* 3. Attack after move (bump) logic if we want, but here we just moved */
}
}
if (acted) {
energy[i].accumulator -= ACTION_COST;
ecs_remove(it->world, monster, MyTurn);
}
}
}
/* System 5f: AI Action - Fleeing behavior */
static void AiActionFleeingSystem(ecs_iter_t *it) {
Energy *energy = ecs_field(it, Energy, 1);
GridPosition *grid = ecs_field(it, GridPosition, 2);
DungeonContext *dungeon = (DungeonContext *)ecs_get_ctx(it->world);
if (!dungeon) return;
ecs_entity_t player = 0;
int player_x = 0, player_y = 0;
find_player(it->world, &player, &player_x, &player_y);
for (int i = 0; i < it->count; i++) {
ecs_entity_t monster = it->entities[i];
int flee_dx = grid[i].x - player_x;
int flee_dy = grid[i].y - player_y;
int dx = (flee_dx > 0) ? 1 : ((flee_dx < 0) ? -1 : 0);
int dy = (flee_dy > 0) ? 1 : ((flee_dy < 0) ? -1 : 0);
int new_x = grid[i].x + dx;
int new_y = grid[i].y + dy;
Tile t = dungeon_context_get_tile(dungeon, new_x, new_y);
if (t != TILE_FLOOR) {
int alts[4][2] = {{dx, 0}, {0, dy}, {-dy, dx}, {dy, -dx}};
dx = 0;
dy = 0;
for (int j = 0; j < 4; j++) {
new_x = grid[i].x + alts[j][0];
new_y = grid[i].y + alts[j][1];
t = dungeon_context_get_tile(dungeon, new_x, new_y);
if (t == TILE_FLOOR) {
dx = alts[j][0];
dy = alts[j][1];
break;
}
}
}
if (dx != 0 || dy != 0) {
execute_move_immediate(it->world, monster, dx, dy);
}
energy[i].accumulator -= ACTION_COST;
ecs_remove(it->world, monster, MyTurn);
}
}
/* System 5g: AI Action - Investigating behavior */
static void AiActionInvestigatingSystem(ecs_iter_t *it) {
Energy *energy = ecs_field(it, Energy, 1);
GridPosition *grid = ecs_field(it, GridPosition, 2);
AiStateInvestigating *inv = ecs_field(it, AiStateInvestigating, 3);
DungeonContext *dungeon = (DungeonContext *)ecs_get_ctx(it->world);
if (!dungeon) return;
for (int i = 0; i < it->count; i++) {
ecs_entity_t monster = it->entities[i];
inv[i].turns_remaining--;
int dx = 0, dy = 0;
int to_x = inv[i].target_x - grid[i].x;
int to_y = inv[i].target_y - grid[i].y;
if (to_x != 0 || to_y != 0) {
dx = (to_x > 0) ? 1 : ((to_x < 0) ? -1 : 0);
dy = (to_y > 0) ? 1 : ((to_y < 0) ? -1 : 0);
int new_x = grid[i].x + dx;
int new_y = grid[i].y + dy;
Tile t = dungeon_context_get_tile(dungeon, new_x, new_y);
if (t != TILE_FLOOR) {
if (abs(to_x) >= abs(to_y)) {
dy = 0;
} else {
dx = 0;
}
new_x = grid[i].x + dx;
new_y = grid[i].y + dy;
t = dungeon_context_get_tile(dungeon, new_x, new_y);
if (t != TILE_FLOOR) {
dx = 0;
dy = 0;
}
}
}
if (dx != 0 || dy != 0) {
execute_move_immediate(it->world, monster, dx, dy);
}
energy[i].accumulator -= ACTION_COST;
ecs_remove(it->world, monster, MyTurn);
}
}
/* System 6: Action Execution System - Execute queued actions (Attacks, Items) */
static void ActionExecutionSystem(ecs_iter_t *it) {
TurnState *ts = ecs_field(it, TurnState, 0);
for (int i = 0; i < it->count; i++) {
if (ts[i].action_count == 0) continue;
rlc_action_queue_sort(&ts[i]);
for (size_t j = 0; j < ts[i].action_count; j++) {
QueuedAction *qa = &ts[i].action_queue[j];
switch (qa->action_type) {
case ACTION_ATTACK: {
if (!ecs_is_alive(it->world, qa->target)) break;
const GridPosition *attacker_pos = ecs_get(it->world, qa->actor, GridPosition);
const GridPosition *target_pos = ecs_get(it->world, qa->target, GridPosition);
if (!attacker_pos || !target_pos) break;
int dx = target_pos->x - attacker_pos->x;
int dy = target_pos->y - attacker_pos->y;
if (abs(dx) > 1 || abs(dy) > 1) break;
/* Attack lunge animation */
{
float lunge_x = (float)dx * 4.0f;
float lunge_y = (float)dy * 4.0f;
if (!ecs_has(it->world, qa->actor, RenderOffset)) {
ecs_set(it->world, qa->actor, RenderOffset, {0.0f, 0.0f});
}
TweenBuilder lunge = TWEEN(it->world, qa->actor);
rlc_tween_duration(&lunge, 0.15f);
rlc_tween_easing(&lunge, EASE_OUT_QUAD);
rlc_tween_repeat(&lunge, TWEEN_MODE_YOYO);
rlc_tween_offset(lunge, lunge_x, lunge_y);
}
/* ── Deterministic weapon-based damage ── */
int raw_damage = 1; /* unarmed fallback */
const WeaponStats *wpn = NULL;
ecs_entity_t weapon_ent = find_equipped_weapon(it->world, qa->actor);
if (weapon_ent) {
wpn = ecs_get(it->world, weapon_ent, WeaponStats);
if (wpn) raw_damage = wpn->base_damage;
} else {
/* Unarmed: 1 + str/3 */
const Attributes *attr = ecs_get(it->world, qa->actor, Attributes);
raw_damage = 1 + (attr ? attr->str / 3 : 0);
}
/* Flat defense from target's armor */
int defense = 0;
ecs_entity_t armor_ent = find_equipped_armor(it->world, qa->target);
if (armor_ent) {
const ArmorStats *arm = ecs_get(it->world, armor_ent, ArmorStats);
if (arm) defense += arm->defense;
}
/* Also check target's Attributes for innate defense (monsters) */
/* Monsters don't equip armor; their defense field IS their defense */
if (!armor_ent) {
const Attributes *tgt_attr = ecs_get(it->world, qa->target, Attributes);
/* con provides innate defense for unarmored entities */
if (tgt_attr) defense += tgt_attr->con / 3;
}
/* Final damage: deterministic, floor 1 */
int damage = raw_damage - defense;
if (damage < 1) damage = 1;
Health *target_hp = ecs_get_mut(it->world, qa->target, Health);
if (target_hp) {
target_hp->current -= damage;
/* Enhanced combat log with damage breakdown */
rlc_log_combat_detailed(it->world, qa->actor, qa->target,
raw_damage, defense, damage, weapon_ent);
/* Stun mechanic: push target's queued attacks forward */
if (wpn && wpn->stun_delay > 0 && target_hp->current > 0) {
for (size_t k = 0; k < ts[i].action_count; k++) {
if (ts[i].action_queue[k].actor == qa->target) {
ts[i].action_queue[k].delay += wpn->stun_delay;
}
}
rlc_action_queue_sort(&ts[i]);
const Name *tgt_name = ecs_get(it->world, qa->target, Name);
rlc_log_message(it->world, LOG_MSG_COMBAT_HIT,
"%s is stunned! (+%d delay)",
tgt_name ? tgt_name->str : "Target", wpn->stun_delay);
}
if (target_hp->current <= 0) {
bool is_target_player = ecs_has(it->world, qa->target, Player);
if (!is_target_player) {
ecs_delete(it->world, qa->target);
} else {
RLC_LOG_INFO("Player killed!");
}
}
} else {
rlc_log_combat_detailed(it->world, qa->actor, qa->target,
0, 0, 0, 0);
}
break;
}
case ACTION_USE_ITEM: {
if (!ecs_is_alive(it->world, qa->item)) break;
const Consumable *cons = ecs_get(it->world, qa->item, Consumable);
if (!cons) break;
bool consumed = false;
switch (cons->effect) {
case CONSUMABLE_EFFECT_HEAL: {
Health *hp = ecs_get_mut(it->world, qa->actor, Health);
if (hp) {
int old_hp = hp->current;
hp->current += cons->power;
if (hp->current > hp->max) hp->current = hp->max;
ecs_modified(it->world, qa->actor, Health);
int healed = hp->current - old_hp;
rlc_log_message(it->world, LOG_MSG_ITEM_USE, "You use a healing potion and restore %d HP", healed);
consumed = true;
}
break;
}
case CONSUMABLE_EFFECT_TELEPORT: {
rlc_log_message(it->world, LOG_MSG_ITEM_USE, "You read a scroll of teleportation!");
consumed = true;
break;
}
case CONSUMABLE_EFFECT_AIZAWA: {
rlc_log_message(it->world, LOG_MSG_ITEM_USE,
"You read the Scroll of Aizawa... reality unravels!");
ecs_add(it->world, qa->actor, AizawaPending);
consumed = true;
break;
}
default: break;
}
if (consumed) ecs_delete(it->world, qa->item);
break;
}
case ACTION_DROP_ITEM: {
if (!ecs_is_alive(it->world, qa->item)) break;
/* Remove all equipment tags */
ecs_remove(it->world, qa->item, EquippedWeapon);
ecs_remove(it->world, qa->item, EquippedArmor);
ecs_remove(it->world, qa->item, EquippedAccessory);
/* Remove inventory slot and containment */
ecs_remove(it->world, qa->item, InventorySlot);
ecs_remove_pair(it->world, qa->item, ContainedBy, qa->actor);
const GridPosition *pos = ecs_get(it->world, qa->actor, GridPosition);
if (pos) {
ecs_add(it->world, qa->item, WorldItem);
ecs_set(it->world, qa->item, GridPosition, {pos->x, pos->y});
ecs_set(it->world, qa->item, RenderPosition, {(float)pos->x, (float)pos->y});
const Name *item_name = ecs_get(it->world, qa->item, Name);
rlc_log_message(it->world, LOG_MSG_SYSTEM, "You drop %s", item_name ? item_name->str : "an item");
}
break;
}
case ACTION_EQUIP_ITEM: {
if (!ecs_is_alive(it->world, qa->item)) break;
const Name *item_name = ecs_get(it->world, qa->item, Name);
/* Check attribute requirements before equipping */
const Attributes *equip_attr = ecs_get(it->world, qa->actor, Attributes);
const WeaponStats *equip_wpn = ecs_get(it->world, qa->item, WeaponStats);
const ArmorStats *equip_arm = ecs_get(it->world, qa->item, ArmorStats);
if (equip_wpn && equip_attr) {
if (equip_attr->str < equip_wpn->req_str) {
rlc_log_message(it->world, LOG_MSG_SYSTEM,
"Need %d STR to wield %s (you have %d)",
equip_wpn->req_str, item_name ? item_name->str : "weapon", equip_attr->str);
break;
}
if (equip_attr->dex < equip_wpn->req_dex) {
rlc_log_message(it->world, LOG_MSG_SYSTEM,
"Need %d DEX to wield %s (you have %d)",
equip_wpn->req_dex, item_name ? item_name->str : "weapon", equip_attr->dex);
break;
}
}
if (equip_arm && equip_attr) {
if (equip_attr->str < equip_arm->req_str) {
rlc_log_message(it->world, LOG_MSG_SYSTEM,
"Need %d STR to wear %s (you have %d)",
equip_arm->req_str, item_name ? item_name->str : "armor", equip_attr->str);
break;
}
}
/* Check item type and toggle equipment tag */
if (ecs_has(it->world, qa->item, IsWeapon)) {
if (ecs_has(it->world, qa->item, EquippedWeapon)) {
ecs_remove(it->world, qa->item, EquippedWeapon);
rlc_log_message(it->world, LOG_MSG_SYSTEM, "You unequip %s", item_name ? item_name->str : "weapon");
} else {
unequip_slot_type(it->world, qa->actor, ecs_id(EquippedWeapon));
ecs_add(it->world, qa->item, EquippedWeapon);
rlc_log_message(it->world, LOG_MSG_SYSTEM, "You equip %s", item_name ? item_name->str : "weapon");
}
} else if (ecs_has(it->world, qa->item, IsArmor)) {
if (ecs_has(it->world, qa->item, EquippedArmor)) {
ecs_remove(it->world, qa->item, EquippedArmor);
rlc_log_message(it->world, LOG_MSG_SYSTEM, "You unequip %s", item_name ? item_name->str : "armor");
} else {
unequip_slot_type(it->world, qa->actor, ecs_id(EquippedArmor));
ecs_add(it->world, qa->item, EquippedArmor);
rlc_log_message(it->world, LOG_MSG_SYSTEM, "You equip %s", item_name ? item_name->str : "armor");
}
} else if (ecs_has(it->world, qa->item, IsAccessory)) {
if (ecs_has(it->world, qa->item, EquippedAccessory)) {
ecs_remove(it->world, qa->item, EquippedAccessory);
rlc_log_message(it->world, LOG_MSG_SYSTEM, "You unequip %s", item_name ? item_name->str : "accessory");
} else {
unequip_slot_type(it->world, qa->actor, ecs_id(EquippedAccessory));
ecs_add(it->world, qa->item, EquippedAccessory);
rlc_log_message(it->world, LOG_MSG_SYSTEM, "You equip %s", item_name ? item_name->str : "accessory");
}
}
break;
}
case ACTION_PICKUP_ITEM: {
if (!ecs_is_alive(it->world, qa->item)) break;
if (!ecs_has(it->world, qa->item, WorldItem)) break;
/* Validate item isn't already in an inventory */
if (ecs_has(it->world, qa->item, InventorySlot)) break;
if (ecs_has_pair(it->world, qa->item, ContainedBy, EcsWildcard)) break;
/* Find free inventory slot */
int slot = find_free_slot(it->world, qa->actor);
if (slot == -1) {
rlc_log_message(it->world, LOG_MSG_SYSTEM, "Inventory full!");
break;
}
/* Add to inventory */
ecs_remove(it->world, qa->item, WorldItem);
ecs_remove(it->world, qa->item, GridPosition);
ecs_remove(it->world, qa->item, RenderPosition);
ecs_add_pair(it->world, qa->item, ContainedBy, qa->actor);
ecs_set(it->world, qa->item, InventorySlot, {.slot_index = slot});
const Name *item_name = ecs_get(it->world, qa->item, Name);
rlc_log_message(it->world, LOG_MSG_ITEM_PICKUP, "You pick up %s", item_name ? item_name->str : "an item");
break;
}
default: break;
}
}
rlc_action_queue_clear(&ts[i]);
}
}
/* Register all turn systems */
void rlc_turn_systems_register(ecs_world_t *world) {
/* 1. Turn Init */
ecs_entity_t sys_turn_init = ecs_system(world, {
.entity = ecs_entity(world, {
.name = "TurnInitSystem",
.add = ecs_ids(ecs_dependson(EcsOnUpdate))
}),
.query.terms = {{ .id = ecs_id(TurnState), .inout = EcsInOut }},
.callback = TurnInitSystem
});
/* 2. Timeline (Time Loop) */
ecs_entity_t sys_timeline = ecs_system(world, {
.entity = ecs_entity(world, {
.name = "TimelineSystem",
.add = ecs_ids(ecs_dependson(EcsOnUpdate))
}),
/* Reads energy to manage turns, adds MyTurn tag */
.callback = TimelineSystem
});
ecs_add_pair(world, sys_timeline, EcsDependsOn, sys_turn_init);
/* 3. Player Path (Requires MyTurn) */
ecs_entity_t sys_player_path = ecs_system(world, {
.entity = ecs_entity(world, {
.name = "PlayerPathFollowSystem",
.add = ecs_ids(ecs_dependson(EcsOnUpdate))
}),
.query.terms = {
{ .id = ecs_id(ActionIntent), .inout = EcsInOut },
{ .id = ecs_id(AStarPath), .inout = EcsInOut },
{ .id = ecs_id(PathStatus), .inout = EcsInOut },
{ .id = ecs_id(Player), .inout = EcsIn },
{ .id = ecs_id(MyTurn), .inout = EcsIn }
},
.callback = PlayerPathFollowSystem
});
ecs_add_pair(world, sys_player_path, EcsDependsOn, sys_timeline);
/* 4. Player Action (Requires MyTurn) */
ecs_entity_t sys_player_action = ecs_system(world, {
.entity = ecs_entity(world, {
.name = "PlayerActionSystem",
.add = ecs_ids(ecs_dependson(EcsOnUpdate))
}),
.query.terms = {
{ .id = ecs_id(ActionIntent), .inout = EcsIn },
{ .id = ecs_id(Energy), .inout = EcsInOut },
{ .id = ecs_id(Actor), .inout = EcsIn },
{ .id = ecs_id(MyTurn), .inout = EcsIn }
},
.callback = PlayerActionSystem
});
ecs_add_pair(world, sys_player_action, EcsDependsOn, sys_player_path);
/* 5. AI Systems (Require MyTurn) */
ecs_entity_t sys_ai_perception = ecs_system(world, {
.entity = ecs_entity(world, {
.name = "AiPerceptionSystem",
.add = ecs_ids(ecs_dependson(EcsOnUpdate))
}),
.query.terms = {
{ .id = ecs_id(Actor), .inout = EcsIn },
{ .id = ecs_id(AiMemory), .inout = EcsInOut },
{ .id = ecs_id(GridPosition), .inout = EcsIn },
{ .id = ecs_id(MyTurn), .inout = EcsIn }
},
.callback = AiPerceptionSystem
});
ecs_add_pair(world, sys_ai_perception, EcsDependsOn, sys_timeline);
ecs_entity_t sys_ai_state = ecs_system(world, {
.entity = ecs_entity(world, {
.name = "AiStateTransitionSystem",
.add = ecs_ids(ecs_dependson(EcsOnUpdate))
}),
.query.terms = {
{ .id = ecs_id(Actor), .inout = EcsIn },
{ .id = ecs_id(AiMemory), .inout = EcsIn },
{ .id = ecs_id(MyTurn), .inout = EcsIn }
},
.callback = AiStateTransitionSystem
});
ecs_add_pair(world, sys_ai_state, EcsDependsOn, sys_ai_perception);
ecs_entity_t sys_ai_wandering = ecs_system(world, {
.entity = ecs_entity(world, { .name = "AiActionWanderingSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }),
.query.terms = {
{ .id = ecs_id(Actor), .inout = EcsIn },
{ .id = ecs_id(Energy), .inout = EcsInOut },
{ .id = ecs_id(GridPosition), .inout = EcsIn },
{ .id = ecs_id(AiStateWandering), .inout = EcsIn },
{ .id = ecs_id(MyTurn), .inout = EcsIn }
},
.callback = AiActionWanderingSystem
});
ecs_add_pair(world, sys_ai_wandering, EcsDependsOn, sys_ai_state);
ecs_entity_t sys_ai_idle = ecs_system(world, {
.entity = ecs_entity(world, { .name = "AiActionIdleSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }),
.query.terms = {
{ .id = ecs_id(Actor), .inout = EcsIn },
{ .id = ecs_id(Energy), .inout = EcsInOut },
{ .id = ecs_id(AiStateIdle), .inout = EcsIn },
{ .id = ecs_id(MyTurn), .inout = EcsIn }
},
.callback = AiActionIdleSystem
});
ecs_add_pair(world, sys_ai_idle, EcsDependsOn, sys_ai_state);
ecs_entity_t sys_ai_hunting = ecs_system(world, {
.entity = ecs_entity(world, { .name = "AiActionHuntingSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }),
.query.terms = {
{ .id = ecs_id(Actor), .inout = EcsIn },
{ .id = ecs_id(Energy), .inout = EcsInOut },
{ .id = ecs_id(GridPosition), .inout = EcsIn },
{ .id = ecs_id(AiStateHunting), .inout = EcsInOut },
{ .id = ecs_id(Actor), .inout = EcsIn },
{ .id = ecs_id(MyTurn), .inout = EcsIn }
},
.callback = AiActionHuntingSystem
});
ecs_add_pair(world, sys_ai_hunting, EcsDependsOn, sys_ai_state);
ecs_entity_t sys_ai_fleeing = ecs_system(world, {
.entity = ecs_entity(world, { .name = "AiActionFleeingSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }),
.query.terms = {
{ .id = ecs_id(Actor), .inout = EcsIn },
{ .id = ecs_id(Energy), .inout = EcsInOut },
{ .id = ecs_id(GridPosition), .inout = EcsIn },
{ .id = ecs_id(AiStateFleeing), .inout = EcsIn },
{ .id = ecs_id(MyTurn), .inout = EcsIn }
},
.callback = AiActionFleeingSystem
});
ecs_add_pair(world, sys_ai_fleeing, EcsDependsOn, sys_ai_state);
ecs_entity_t sys_ai_investigating = ecs_system(world, {
.entity = ecs_entity(world, { .name = "AiActionInvestigatingSystem", .add = ecs_ids(ecs_dependson(EcsOnUpdate)) }),
.query.terms = {
{ .id = ecs_id(Actor), .inout = EcsIn },
{ .id = ecs_id(Energy), .inout = EcsInOut },
{ .id = ecs_id(GridPosition), .inout = EcsIn },
{ .id = ecs_id(AiStateInvestigating), .inout = EcsInOut },
{ .id = ecs_id(MyTurn), .inout = EcsIn }
},
.callback = AiActionInvestigatingSystem
});
ecs_add_pair(world, sys_ai_investigating, EcsDependsOn, sys_ai_state);
/* 6. Action Execution (Runs after everyone has had a chance to queue things) */
ecs_entity_t sys_action_exec = ecs_system(world, {
.entity = ecs_entity(world, {
.name = "ActionExecutionSystem",
.add = ecs_ids(ecs_dependson(EcsOnUpdate))
}),
.query.terms = {{ .id = ecs_id(TurnState), .inout = EcsInOut }},
.callback = ActionExecutionSystem
});
/* Run after all AI and Player systems */
ecs_add_pair(world, sys_action_exec, EcsDependsOn, sys_ai_investigating);
ecs_add_pair(world, sys_action_exec, EcsDependsOn, sys_player_action);
/* Register NPC AI systems (schedule, movement, alert, flee) */
rlc_npc_ai_systems_register(world);
}