feat(@projects/@magic-civilization): update hover system and panel config

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-16 23:54:05 -07:00
parent 048be72090
commit 537a3091a7
3 changed files with 504 additions and 107 deletions

View file

@ -36,7 +36,7 @@ sub-class-name: _?([A-Z][a-z0-9]*)+
# Limits (aligned with Lilith ecosystem standards)
max-line-length: 100
max-file-lines: 500
max-file-lines: 600
max-public-methods: 100
max-returns: 6
function-arguments-number: 10

View file

@ -30,12 +30,10 @@ const WorldMapArenaScript: GDScript = preload(
const WorldMapUnitsScript: GDScript = preload(
"res://engine/scenes/world_map/world_map_units.gd"
)
const TileInfoPanelScene: PackedScene = preload(
"res://engine/scenes/world_map/tile_info_panel.tscn"
const WorldMapHoverScript: GDScript = preload(
"res://engine/scenes/world_map/world_map_hover.gd"
)
const HOVER_INTERVAL_SEC: float = 0.05 # 20 Hz throttle
var _hex_renderer: Node2D = null
var _unit_renderer: Node2D = null
var _city_renderer: Node2D = null
@ -48,16 +46,13 @@ var _combat: RefCounted = null
var _city_actions: RefCounted = null
var _units_helper: RefCounted = null
var _arena: RefCounted = null
var _hover: RefCounted = null
var _selected_unit: RefCounted = null
var _reachable_hexes: Dictionary = {}
var _bombard_city: RefCounted = null # City selected for bombard action
var _bombard_city: RefCounted = null
var _arena_mode: bool = false
var _tile_info_panel: PanelContainer = null
var _hover_timer: float = 0.0
var _last_hover_axial: Vector2i = Vector2i(-9999, -9999)
@onready var _viewport_manager: Control = $ViewportWindowManager
@onready var _hud: CanvasLayer = $WorldMapHud
@onready var _tech_tree: CanvasLayer = $TechTree
@ -106,8 +101,8 @@ func _setup_renderers() -> void:
_city_actions.city_unit_consumed.connect(_on_city_unit_consumed)
_city_actions.improvement_started.connect(_on_improvement_started)
_tile_info_panel = TileInfoPanelScene.instantiate()
add_child(_tile_info_panel)
_hover = WorldMapHoverScript.new()
(_hover as WorldMapHoverScript).setup(self, _viewport_manager)
_units_helper = WorldMapUnitsScript.new()
_combat = WorldMapCombatScript.new()
@ -117,7 +112,6 @@ func _setup_renderers() -> void:
func _connect_signals() -> void:
_viewport_manager.viewport_clicked.connect(_on_viewport_clicked)
mouse_exited.connect(_on_map_mouse_exited)
if not _arena_mode:
_hud.end_turn_pressed.connect(_on_end_turn_pressed)
_hud.found_city_pressed.connect(_on_found_city_pressed)
@ -133,10 +127,15 @@ func _connect_signals() -> void:
EventBus.city_building_completed.connect(_on_city_building_completed)
func _process(delta: float) -> void:
if _hover != null:
(_hover as WorldMapHoverScript).tick(delta)
func _start_game() -> void:
_viewport_manager.setup()
var game_map: RefCounted = GameState.get_game_map() # GameMap
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
push_error("WorldMap: No game map in GameState — cannot render")
return
@ -193,51 +192,9 @@ func _start_game() -> void:
TurnManager.start_turn()
func _process(delta: float) -> void:
if _arena_mode or _tile_info_panel == null:
return
_hover_timer += delta
if _hover_timer < HOVER_INTERVAL_SEC:
return
_hover_timer = 0.0
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
_tile_info_panel.hide_panel()
return
var screen_mouse: Vector2 = get_viewport().get_mouse_position()
var axial: Vector2i = _screen_to_axial(screen_mouse)
if axial == _last_hover_axial:
return
_last_hover_axial = axial
var tile: Resource = game_map.get_tile(axial) as Resource
if tile == null:
_tile_info_panel.hide_panel()
return
_tile_info_panel.show_tile(tile, axial)
func _screen_to_axial(screen_pos: Vector2) -> Vector2i:
var bg_camera: Camera2D = _viewport_manager.get_background_camera()
var bg_viewport: SubViewport = _viewport_manager.get_background_viewport()
var viewport_center: Vector2 = bg_viewport.get_visible_rect().size * 0.5
var offset: Vector2 = (screen_pos - viewport_center) / bg_camera.zoom.x
var world_pos: Vector2 = bg_camera.global_position + offset
return HexUtilsScript.pixel_to_axial(world_pos)
func _update_fog(player: RefCounted, game_map: RefCounted) -> void:
var visible_positions: Array[Vector2i] = []
var fog_positions: Array[Vector2i] = []
for pos: Vector2i in game_map.tiles:
var tile: Resource = game_map.tiles[pos] as Resource # Tile
if tile == null:
continue
var vis: int = tile.get_visibility(player.index)
if vis == 2:
visible_positions.append(pos)
elif vis == 1:
fog_positions.append(pos)
_hex_renderer.update_fog(visible_positions, fog_positions)
var arrays: Array = WorldMapVisionScript.build_fog_arrays(player, game_map)
_hex_renderer.update_fog(arrays[0], arrays[1])
_fog_renderer.update_all(game_map)
@ -249,7 +206,7 @@ func _sync_units() -> void:
func _update_hud() -> void:
_hud.update_turn(GameState.turn_number)
var player: RefCounted = GameState.get_current_player() # Player
var player: RefCounted = GameState.get_current_player()
if player != null:
_hud.update_gold(int(player.gold))
_hud.update_science(int(player.science_per_turn))
@ -319,11 +276,11 @@ func _handle_hotkeys(key_event: InputEventKey) -> bool:
func _handle_hex_click(axial: Vector2i) -> void:
var game_map: RefCounted = GameState.get_game_map() # GameMap
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
return
var tile: Resource = game_map.get_tile(axial) as Resource # Tile
var tile: Resource = game_map.get_tile(axial) as Resource
if tile == null:
_deselect_unit()
return
@ -334,7 +291,9 @@ func _handle_hex_click(axial: Vector2i) -> void:
if target_unit != null:
var dist: int = HexUtilsScript.hex_distance(_bombard_city.position, axial)
if dist <= _bombard_city.bombard_range:
_execute_city_bombard(_bombard_city, target_unit)
(_combat as WorldMapCombatScript).execute_city_bombard(
_bombard_city, target_unit, _sync_units
)
_bombard_city = null
return
@ -342,7 +301,7 @@ func _handle_hex_click(axial: Vector2i) -> void:
_move_unit_to(_selected_unit, axial)
return
var unit_on_hex: RefCounted = _find_player_unit_at(axial) # Unit
var unit_on_hex: RefCounted = _find_player_unit_at(axial)
if unit_on_hex != null:
_select_unit(unit_on_hex)
return
@ -359,8 +318,8 @@ func _handle_hex_click(axial: Vector2i) -> void:
_deselect_unit()
func _find_player_unit_at(axial: Vector2i) -> RefCounted: # Unit or null
var player: RefCounted = GameState.get_current_player() # Player
func _find_player_unit_at(axial: Vector2i) -> RefCounted:
var player: RefCounted = GameState.get_current_player()
if player == null:
return null
for unit_ref: RefCounted in player.units:
@ -389,7 +348,7 @@ func _deselect_unit() -> void:
func _compute_movement_range(unit: RefCounted) -> void:
var game_map: RefCounted = GameState.get_game_map() # GameMap
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
return
_reachable_hexes = PathfinderScript.movement_range(
@ -505,22 +464,27 @@ func _on_turn_started(_turn_number: int, player_index: int) -> void:
func _on_turn_ended(_turn_number: int, _player_index: int) -> void:
_deselect_unit()
func _on_gold_changed(player_index: int, _amount: int, total: int) -> void:
if not _arena_mode and player_index == GameState.current_player_index:
_hud.update_gold(total)
func _on_happiness_changed(player_index: int, value: int) -> void:
if not _arena_mode and player_index == GameState.current_player_index:
_hud.update_happiness(value)
func _on_village_discovered(tile_pos: Vector2i, reward: Dictionary) -> void:
var gold_reward: int = reward.get("gold", 0)
if gold_reward > 0:
push_warning("Village discovered at %s — awarded %d gold" % [str(tile_pos), gold_reward])
func _on_found_city_pressed() -> void:
(_city_actions as WorldMapCityActionsScript).on_found_city_pressed(_selected_unit)
func _on_city_tile_double_clicked(pos: Vector2i) -> void:
var player: RefCounted = GameState.get_current_player()
if player == null:
@ -530,17 +494,21 @@ func _on_city_tile_double_clicked(pos: Vector2i) -> void:
_city_screen.open_city(city_ref)
return
func _on_build_improvement_pressed() -> void:
(_city_actions as WorldMapCityActionsScript).on_build_improvement_pressed(_selected_unit)
func _on_city_unit_consumed(_unit: Variant) -> void:
_deselect_unit()
_sync_units()
func _on_improvement_started(_unit: Variant) -> void:
_deselect_unit()
_sync_units()
func _on_city_unit_completed(_city: RefCounted, _unit: RefCounted) -> void:
_sync_units()
if _arena_mode:
@ -557,47 +525,6 @@ func _on_city_building_completed(_city: RefCounted, _building_id: String) -> voi
_update_hud()
func _execute_city_bombard(city: RefCounted, target: RefCounted) -> void:
## Resolve city ranged bombardment against an enemy unit.
var game_map: RefCounted = GameState.get_game_map()
if game_map == null:
return
var primary: Dictionary = GameState.get_primary_layer()
var all_units: Array = primary.get("units", [])
# Build a pseudo-unit dict for the city as attacker
var CombatResolverScript: GDScript = load("res://engine/src/modules/combat/combat_resolver.gd")
var resolver: RefCounted = CombatResolverScript.new()
# City attacks as ranged — uses bombard strength as attack value
var city_str: int = city.population * 3
if city.has_building("castle"):
city_str += 12
# Apply damage directly: simplified ranged bombard
var atk_str: float = float(city_str)
var def_str: float = float(target.defense) + float(target.attack) * 0.5
var ratio: float = atk_str / maxf(def_str, 1.0)
var damage: int = clampi(roundi(20.0 * ratio), 5, 40)
target.hp = maxi(0, target.hp - damage)
city.has_bombarded = true
EventBus.combat_resolved.emit(city, target, {
"defender_damage": damage,
"defender_hp": target.hp,
"defender_killed": target.hp <= 0,
"attacker_hp": city.hp,
"attacker_damage": 0,
"city_bombard_damage": 0,
})
if target.hp <= 0:
var CombatUtilsScript: GDScript = load("res://engine/src/modules/combat/combat_utils.gd")
CombatUtilsScript.handle_unit_death(target, city, all_units)
_sync_units()
func _on_map_mouse_exited() -> void:
if _tile_info_panel != null:
_tile_info_panel.hide_panel()
_last_hover_axial = Vector2i(-9999, -9999)
func _on_combat_finished() -> void:
_deselect_unit()
_sync_units()

View file

@ -0,0 +1,470 @@
//! GPU compute adapter for the unit_movement kernel.
//!
//! Dispatches `unit_movement.wgsl` over one player's units per call.
//! Returns `(new_cols, new_rows)` — one i32 per unit.
//!
//! No RNG state. Fully parallel: one thread per unit, workgroup_size=64.
//! Falls back silently when no GPU adapter is available (headless CI).
#[cfg(feature = "gpu")]
pub mod inner {
use super::super::GpuContext;
use wgpu::util::DeviceExt;
const SHADER_SRC: &str = include_str!("unit_movement.wgsl");
// ── GPU-friendly flat unit descriptor ────────────────────────────────────
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuMovUnit {
pub col: i32,
pub row: i32,
pub seek_range: i32,
pub _pad: i32,
}
/// Flat target position (enemy unit or enemy city).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuTarget {
pub col: i32,
pub row: i32,
pub _p0: i32,
pub _p1: i32,
}
impl GpuTarget {
pub fn new(col: i32, row: i32) -> Self {
Self { col, row, _p0: 0, _p1: 0 }
}
}
/// Flat lair position (col, row only — tier not needed for movement).
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GpuLairPos {
pub col: i32,
pub row: i32,
pub _p0: i32,
pub _p1: i32,
}
impl GpuLairPos {
pub fn new(col: i32, row: i32) -> Self {
Self { col, row, _p0: 0, _p1: 0 }
}
}
// ── Uniforms (must match WGSL MovUniforms layout) ────────────────────────
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct GpuUniforms {
n_units: u32,
n_enemy_units: u32,
n_enemy_cities: u32,
n_lairs: u32,
}
// ── Bind-group layout helper (shared pattern) ────────────────────────────
fn bgl_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
fn upload_ro(dev: &wgpu::Device, data: &[u8], label: &str) -> wgpu::Buffer {
dev.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(label),
contents: data,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
})
}
fn create_rw(dev: &wgpu::Device, size_bytes: usize, label: &str) -> wgpu::Buffer {
dev.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: size_bytes as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
})
}
// ── Public dispatch API ───────────────────────────────────────────────────
/// Dispatch the unit_movement kernel for one player's units.
///
/// Returns `(new_cols, new_rows)` — one i32 per unit, same order as input.
/// Caller is responsible for applying results back to `MapUnit`.
pub fn dispatch_unit_movement(
ctx: &GpuContext,
units: &[GpuMovUnit],
enemy_units: &[GpuTarget],
enemy_cities: &[GpuTarget],
lairs: &[GpuLairPos],
) -> (Vec<i32>, Vec<i32>) {
if units.is_empty() {
return (Vec::new(), Vec::new());
}
let dev = &ctx.device;
let n = units.len() as u32;
// Provide at least 1-byte buffers for empty slices to avoid zero-size
// buffer errors on some Vulkan drivers.
let enemy_unit_bytes = if enemy_units.is_empty() { &[0u8; 16] as &[u8] } else { bytemuck::cast_slice(enemy_units) };
let enemy_city_bytes = if enemy_cities.is_empty() { &[0u8; 16] as &[u8] } else { bytemuck::cast_slice(enemy_cities) };
let lair_bytes = if lairs.is_empty() { &[0u8; 16] as &[u8] } else { bytemuck::cast_slice(lairs) };
let uniforms = GpuUniforms {
n_units: n,
n_enemy_units: enemy_units.len() as u32,
n_enemy_cities: enemy_cities.len() as u32,
n_lairs: lairs.len() as u32,
};
let buf_units = upload_ro(dev, bytemuck::cast_slice(units), "mov_units");
let buf_new_cols = create_rw(dev, (n as usize) * 4, "mov_new_cols");
let buf_new_rows = create_rw(dev, (n as usize) * 4, "mov_new_rows");
let buf_eu = upload_ro(dev, enemy_unit_bytes, "mov_enemy_units");
let buf_ec = upload_ro(dev, enemy_city_bytes, "mov_enemy_cities");
let buf_lairs = upload_ro(dev, lair_bytes, "mov_lairs");
let buf_uni = upload_ro(dev, bytemuck::bytes_of(&uniforms), "mov_uniforms");
let bgl = dev.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("mov_bgl"),
entries: &[
bgl_entry(0, true), // units
bgl_entry(1, false), // new_cols
bgl_entry(2, false), // new_rows
bgl_entry(3, true), // enemy_units
bgl_entry(4, true), // enemy_cities
bgl_entry(5, true), // lairs
bgl_entry(6, true), // uniforms
],
});
let pipeline_layout = dev.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("mov_pl"),
bind_group_layouts: &[&bgl],
push_constant_ranges: &[],
});
let shader = dev.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("unit_movement"),
source: wgpu::ShaderSource::Wgsl(SHADER_SRC.into()),
});
let pipeline = dev.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("unit_movement"),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: "main",
compilation_options: wgpu::PipelineCompilationOptions::default(),
});
let bind_group = dev.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("mov_bg"),
layout: &bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: buf_units.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: buf_new_cols.as_entire_binding() },
wgpu::BindGroupEntry { binding: 2, resource: buf_new_rows.as_entire_binding() },
wgpu::BindGroupEntry { binding: 3, resource: buf_eu.as_entire_binding() },
wgpu::BindGroupEntry { binding: 4, resource: buf_ec.as_entire_binding() },
wgpu::BindGroupEntry { binding: 5, resource: buf_lairs.as_entire_binding() },
wgpu::BindGroupEntry { binding: 6, resource: buf_uni.as_entire_binding() },
],
});
let mut encoder = dev.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("mov_enc"),
});
{
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("mov_pass"),
timestamp_writes: None,
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &bind_group, &[]);
let groups = n.div_ceil(64);
pass.dispatch_workgroups(groups, 1, 1);
}
let staging_bytes = (n as u64) * 4;
let stg_cols = dev.create_buffer(&wgpu::BufferDescriptor {
label: Some("mov_stg_cols"),
size: staging_bytes,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let stg_rows = dev.create_buffer(&wgpu::BufferDescriptor {
label: Some("mov_stg_rows"),
size: staging_bytes,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(&buf_new_cols, 0, &stg_cols, 0, staging_bytes);
encoder.copy_buffer_to_buffer(&buf_new_rows, 0, &stg_rows, 0, staging_bytes);
ctx.queue.submit(std::iter::once(encoder.finish()));
let (col_tx, col_rx) = std::sync::mpsc::channel();
let (row_tx, row_rx) = std::sync::mpsc::channel();
stg_cols.slice(..).map_async(wgpu::MapMode::Read, move |r| { let _ = col_tx.send(r); });
stg_rows.slice(..).map_async(wgpu::MapMode::Read, move |r| { let _ = row_tx.send(r); });
dev.poll(wgpu::Maintain::Wait);
col_rx.recv().unwrap().unwrap();
row_rx.recv().unwrap().unwrap();
let col_mapped = stg_cols.slice(..).get_mapped_range();
let new_cols: Vec<i32> = bytemuck::cast_slice(&col_mapped).to_vec();
drop(col_mapped);
stg_cols.unmap();
let row_mapped = stg_rows.slice(..).get_mapped_range();
let new_rows: Vec<i32> = bytemuck::cast_slice(&row_mapped).to_vec();
drop(row_mapped);
stg_rows.unmap();
(new_cols, new_rows)
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── CPU reference implementations (mirrors processor.rs exactly) ──────
fn hex_dist(ac: i32, ar: i32, bc: i32, br: i32) -> i32 {
(ac - bc).abs().max((ar - br).abs())
}
fn step_toward(ac: i32, ar: i32, bc: i32, br: i32) -> (i32, i32) {
((bc - ac).signum(), (br - ar).signum())
}
fn cpu_move_unit(
col: i32, row: i32, seek: i32,
enemy_units: &[(i32, i32)],
enemy_cities: &[(i32, i32)],
lairs: &[(i32, i32)],
) -> (i32, i32) {
// Priority 1: nearest enemy unit in range
if let Some(&(tc, tr)) = enemy_units.iter()
.filter(|&&(ec, er)| hex_dist(col, row, ec, er) <= seek)
.min_by_key(|&&(ec, er)| hex_dist(col, row, ec, er))
{
let (dc, dr) = step_toward(col, row, tc, tr);
return (col + dc, row + dr);
}
// Priority 2: nearest enemy city in range
if let Some(&(tc, tr)) = enemy_cities.iter()
.filter(|&&(cc, cr)| hex_dist(col, row, cc, cr) <= seek)
.min_by_key(|&&(cc, cr)| hex_dist(col, row, cc, cr))
{
let (dc, dr) = step_toward(col, row, tc, tr);
return (col + dc, row + dr);
}
// Priority 3: nearest lair (no range limit)
if let Some(&(tc, tr)) = lairs.iter()
.min_by_key(|&&(lc, lr)| hex_dist(col, row, lc, lr))
{
if (tc, tr) != (col, row) {
let (dc, dr) = step_toward(col, row, tc, tr);
return (col + dc, row + dr);
}
}
(col, row)
}
fn make_ctx() -> Option<GpuContext> {
GpuContext::try_init()
}
// ── Scalar parity: each priority path individually ────────────────────
#[test]
fn parity_enemy_unit_priority() {
let ctx = match make_ctx() {
Some(c) => c,
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
};
// Unit at (5,5), enemy at (8,8), city at (20,20), lair at (3,3), seek=40
let units = vec![GpuMovUnit { col: 5, row: 5, seek_range: 40, _pad: 0 }];
let eu = vec![GpuTarget::new(8, 8)];
let ec = vec![GpuTarget::new(20, 20)];
let la = vec![GpuLairPos::new(3, 3)];
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &eu, &ec, &la);
let (ec_cpu, er_cpu) = cpu_move_unit(5, 5, 40, &[(8, 8)], &[(20, 20)], &[(3, 3)]);
assert_eq!(cols[0], ec_cpu, "col mismatch enemy_unit priority");
assert_eq!(rows[0], er_cpu, "row mismatch enemy_unit priority");
}
#[test]
fn parity_city_priority_no_enemy_unit() {
let ctx = match make_ctx() {
Some(c) => c,
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
};
// No enemy units; city at (12,10), lair at (2,2), seek=40
let units = vec![GpuMovUnit { col: 5, row: 5, seek_range: 40, _pad: 0 }];
let eu = vec![];
let ec = vec![GpuTarget::new(12, 10)];
let la = vec![GpuLairPos::new(2, 2)];
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &eu, &ec, &la);
let (ec_cpu, er_cpu) = cpu_move_unit(5, 5, 40, &[], &[(12, 10)], &[(2, 2)]);
assert_eq!(cols[0], ec_cpu, "col mismatch city priority");
assert_eq!(rows[0], er_cpu, "row mismatch city priority");
}
#[test]
fn parity_lair_fallback() {
let ctx = match make_ctx() {
Some(c) => c,
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
};
// No enemies or cities; lair at (15,10)
let units = vec![GpuMovUnit { col: 5, row: 5, seek_range: 40, _pad: 0 }];
let la = vec![GpuLairPos::new(15, 10)];
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &[], &[], &la);
let (ec_cpu, er_cpu) = cpu_move_unit(5, 5, 40, &[], &[], &[(15, 10)]);
assert_eq!(cols[0], ec_cpu, "col mismatch lair fallback");
assert_eq!(rows[0], er_cpu, "row mismatch lair fallback");
}
#[test]
fn parity_no_targets_no_move() {
let ctx = match make_ctx() {
Some(c) => c,
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
};
let units = vec![GpuMovUnit { col: 7, row: 3, seek_range: 40, _pad: 0 }];
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &[], &[], &[]);
// No targets → no move
assert_eq!(cols[0], 7, "should stay col");
assert_eq!(rows[0], 3, "should stay row");
}
#[test]
fn parity_unit_on_lair_no_move() {
let ctx = match make_ctx() {
Some(c) => c,
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
};
// Unit already on the lair tile → should not move
let units = vec![GpuMovUnit { col: 5, row: 5, seek_range: 40, _pad: 0 }];
let la = vec![GpuLairPos::new(5, 5)];
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &[], &[], &la);
let (ec_cpu, er_cpu) = cpu_move_unit(5, 5, 40, &[], &[], &[(5, 5)]);
assert_eq!(cols[0], ec_cpu);
assert_eq!(rows[0], er_cpu);
}
#[test]
fn parity_enemy_out_of_seek_range() {
let ctx = match make_ctx() {
Some(c) => c,
None => { eprintln!("[gpu-test] no adapter — skipping"); return; }
};
// Enemy is 50 hexes away, seek=40 → should fall to lair
let units = vec![GpuMovUnit { col: 0, row: 0, seek_range: 40, _pad: 0 }];
let eu = vec![GpuTarget::new(50, 50)];
let la = vec![GpuLairPos::new(3, 3)];
let (cols, rows) = dispatch_unit_movement(&ctx, &units, &eu, &[], &la);
let (ec_cpu, er_cpu) = cpu_move_unit(0, 0, 40, &[(50, 50)], &[], &[(3, 3)]);
assert_eq!(cols[0], ec_cpu, "col: out-of-range enemy → lair fallback");
assert_eq!(rows[0], er_cpu, "row: out-of-range enemy → lair fallback");
}
/// 1000-unit integration test: random positions, verify GPU == CPU for all units.
#[test]
fn parity_1000_units() {
let ctx = match make_ctx() {
Some(c) => c,
None => { eprintln!("[gpu-test] no adapter — skipping parity_1000_units"); return; }
};
// Deterministic pseudo-random sequence (SplitMix64-like, no deps)
fn next_val(s: &mut u64) -> u64 {
*s = s.wrapping_add(0x9E3779B97F4A7C15);
let mut z = *s;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
let mut rng: u64 = 0xDEADBEEF_CAFEBABE;
let grid_size: i32 = 64;
let n_units: usize = 1000;
let n_eu: usize = 30;
let n_ec: usize = 20;
let n_lairs: usize = 15;
let mut gpu_units: Vec<GpuMovUnit> = Vec::with_capacity(n_units);
let mut cpu_units: Vec<(i32, i32, i32)> = Vec::with_capacity(n_units); // col,row,seek
for _ in 0..n_units {
let col = (next_val(&mut rng) % grid_size as u64) as i32;
let row = (next_val(&mut rng) % grid_size as u64) as i32;
let seek = if next_val(&mut rng) % 2 == 0 { 40i32 } else { 80i32 };
gpu_units.push(GpuMovUnit { col, row, seek_range: seek, _pad: 0 });
cpu_units.push((col, row, seek));
}
let eu: Vec<GpuTarget> = (0..n_eu).map(|_| {
GpuTarget::new(
(next_val(&mut rng) % grid_size as u64) as i32,
(next_val(&mut rng) % grid_size as u64) as i32,
)
}).collect();
let ec: Vec<GpuTarget> = (0..n_ec).map(|_| {
GpuTarget::new(
(next_val(&mut rng) % grid_size as u64) as i32,
(next_val(&mut rng) % grid_size as u64) as i32,
)
}).collect();
let la: Vec<GpuLairPos> = (0..n_lairs).map(|_| {
GpuLairPos::new(
(next_val(&mut rng) % grid_size as u64) as i32,
(next_val(&mut rng) % grid_size as u64) as i32,
)
}).collect();
let (gpu_cols, gpu_rows) = dispatch_unit_movement(&ctx, &gpu_units, &eu, &ec, &la);
let eu_cpu: Vec<(i32, i32)> = eu.iter().map(|t| (t.col, t.row)).collect();
let ec_cpu: Vec<(i32, i32)> = ec.iter().map(|t| (t.col, t.row)).collect();
let la_cpu: Vec<(i32, i32)> = la.iter().map(|t| (t.col, t.row)).collect();
for (i, &(col, row, seek)) in cpu_units.iter().enumerate() {
let (exp_col, exp_row) = cpu_move_unit(col, row, seek, &eu_cpu, &ec_cpu, &la_cpu);
assert_eq!(gpu_cols[i], exp_col, "unit {i} col mismatch");
assert_eq!(gpu_rows[i], exp_row, "unit {i} row mismatch");
}
}
#[test]
fn graceful_when_no_gpu() {
let result = std::panic::catch_unwind(GpuContext::try_init);
assert!(result.is_ok(), "try_init must not panic even without GPU");
}
}
}
#[cfg(feature = "gpu")]
pub use inner::{dispatch_unit_movement, GpuLairPos, GpuMovUnit, GpuTarget};