From 981e856200dfeb338936ddec4bc23619908d3130 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 10 May 2026 23:09:43 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=85=20improve=20land=20tile=20handling=20for=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engine/scenes/tests/gameplay_arc_proof.gd | 102 +++++++++++++++--- 1 file changed, 85 insertions(+), 17 deletions(-) diff --git a/src/game/engine/scenes/tests/gameplay_arc_proof.gd b/src/game/engine/scenes/tests/gameplay_arc_proof.gd index 52156c5b..02b036ab 100644 --- a/src/game/engine/scenes/tests/gameplay_arc_proof.gd +++ b/src/game/engine/scenes/tests/gameplay_arc_proof.gd @@ -88,8 +88,14 @@ func _ready() -> void: _p1 = GameState.players[0] as PlayerScript _p2 = GameState.players[1] as PlayerScript - _p1_pos = _p1.units[0].position - _p2_pos = _p2.units[0].position + # Honour FORBIDDEN_BIOMES — relocate any founder that the harness dropped + # onto ocean/coast/mountains to the nearest land tile so cities never + # get founded on water in the demo (matches the production game's + # `mc-mapgen::spawn_box` gate). See the user-reported issue. + _p1_pos = _find_land_tile_near(_p1.units[0].position, 12) + _p2_pos = _find_land_tile_near(_p2.units[0].position, 12) + _p1.units[0].position = _p1_pos + _p2.units[0].position = _p2_pos _setup_renderers() _setup_camera_fit() @@ -131,8 +137,18 @@ func _ready() -> void: _c1.set_population(7) await _capture("07_capital_pop7", "focus", _p1_pos, 6.0) - # Add placed buildings around the capital - var ring1: Array[Vector2i] = _hex_ring(_p1_pos, 1) + # Add placed buildings around the capital. Filter the ring to land + # tiles only so the building icons never overlap ocean/coast hexes — + # the visual demo should respect the same constraints the production + # city-tile picker enforces (mc-city::tile_picker rejects water tiles). + var ring1: Array[Vector2i] = _filter_land(_hex_ring(_p1_pos, 1)) + if ring1.size() < 4: + # Top up from ring 2 if ring 1 had ocean/coast hexes pruned out. + var ring2_land: Array[Vector2i] = _filter_land(_hex_ring(_p1_pos, 2)) + for r2: Vector2i in ring2_land: + if ring1.size() >= 4: + break + ring1.append(r2) _c1.placed_buildings = { "dwarf_granary": ring1[0] if ring1.size() > 0 else _p1_pos, "dwarf_forge": ring1[1] if ring1.size() > 1 else _p1_pos, @@ -164,9 +180,12 @@ func _ready() -> void: await _capture("12_two_culture_zones_wide", "fit") # ── ACT IV: Military buildup ─────────────────────────────────────────── + # Filter the ring to land tiles so warriors never spawn on ocean — + # matches `pathfinder.gd::_is_passable` rejecting `water` in the + # unit's terrain flags for land units. var spawned: int = 0 for n_pos: Vector2i in ring1: - if not _game_map.tiles.has(n_pos): + if not _is_land_tile(n_pos): continue var u: UnitScript = UnitScript.new("dwarf_warrior", 0, n_pos) u.id = "p1_army_%d" % spawned @@ -176,24 +195,22 @@ func _ready() -> void: break await _capture("13_p1_army_3warriors", "focus", _p1_pos, 7.0) - # Add a mixed army: archer + scout - var ring2: Array[Vector2i] = _hex_ring(_p1_pos, 2) - if ring2.size() > 1 and _game_map.tiles.has(ring2[0]): - var arch: UnitScript = UnitScript.new("dwarf_archer", 0, ring2[0]) + # Add a mixed army: archer + scout — same land-only filter. + var ring2_land: Array[Vector2i] = _filter_land(_hex_ring(_p1_pos, 2)) + if ring2_land.size() > 0: + var arch: UnitScript = UnitScript.new("dwarf_archer", 0, ring2_land[0]) arch.id = "p1_archer" _p1.units.append(arch) - if ring2.size() > 2 and _game_map.tiles.has(ring2[1]): - var sct: UnitScript = UnitScript.new("dwarf_scout", 0, ring2[1]) + if ring2_land.size() > 1: + var sct: UnitScript = UnitScript.new("dwarf_scout", 0, ring2_land[1]) sct.id = "p1_scout" _p1.units.append(sct) await _capture("14_mixed_army", "focus", _p1_pos, 9.0) - # P2 puts up a defensive force too - for j: int in range(2): - var n2: Vector2i = _hex_ring(_p2_pos, 1)[j] if _hex_ring(_p2_pos, 1).size() > j else _p2_pos - if not _game_map.tiles.has(n2): - continue - var u2: UnitScript = UnitScript.new("dwarf_warrior", 1, n2) + # P2 puts up a defensive force too — land-only. + var p2_ring1_land: Array[Vector2i] = _filter_land(_hex_ring(_p2_pos, 1)) + for j: int in range(min(2, p2_ring1_land.size())): + var u2: UnitScript = UnitScript.new("dwarf_warrior", 1, p2_ring1_land[j]) u2.id = "p2_army_%d" % j _p2.units.append(u2) await _capture("15_p2_defenders", "focus", _p2_pos, 7.0) @@ -518,3 +535,54 @@ func _hex_ring(centre: Vector2i, radius: int) -> Array[Vector2i]: result.append(current) current = current + dirs[side] return result + + +## True when `pos` is a land tile a city or land unit can occupy. Mirrors +## `mc_mapgen::spawn_box::FORBIDDEN_BIOMES` (ocean / coast / mountains) +## and the GDScript `pathfinder.gd::_is_passable` water/impassable gates. +## Defensive: missing tile, missing biome string, or non-land biome all +## return false so demo placements never land in the sea. +func _is_land_tile(pos: Vector2i) -> bool: + if not _game_map.tiles.has(pos): + return false + var tile: Resource = _game_map.tiles[pos] + if tile == null: + return false + var biome: String = "" + if "biome_id" in tile: + biome = String(tile.biome_id) + elif "biome_label_id" in tile: + biome = String(tile.biome_label_id) + if biome.is_empty(): + return false + # Forbidden biomes for cities + land-unit footprints. Mountains are + # walkable in some game modes but never a valid capital site, so we + # treat them as forbidden for the demo's placement helpers. + const FORBIDDEN: Array[String] = [ + "ocean", "deep_ocean", "coast", "inland_sea", "lake", + "mountains", "volcano", "ice", + ] + return biome not in FORBIDDEN + + +## Pick the first land tile in concentric rings expanding outward from +## `centre`, scanning up to `max_radius`. Returns `centre` when nothing +## land-eligible is found so the demo still proceeds (the projector will +## render the city dot regardless). +func _find_land_tile_near(centre: Vector2i, max_radius: int) -> Vector2i: + if _is_land_tile(centre): + return centre + for r: int in range(1, max_radius + 1): + for candidate: Vector2i in _hex_ring(centre, r): + if _is_land_tile(candidate): + return candidate + return centre + + +## Filter `positions` down to land-only tiles. Order-preserving. +func _filter_land(positions: Array[Vector2i]) -> Array[Vector2i]: + var out: Array[Vector2i] = [] + for p: Vector2i in positions: + if _is_land_tile(p): + out.append(p) + return out