From e0b37c3b60aa82454e1a15965f6bd37cc7693860 Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 16 Apr 2026 13:52:26 -0700 Subject: [PATCH] =?UTF-8?q?feat(generation):=20=E2=9C=A8=20Introduce=20bal?= =?UTF-8?q?anced=20start=20position=20generation=20algorithm=20for=20multi?= =?UTF-8?q?player=20maps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/game/engine/src/generation/map_placer.gd | 27 ++++++++++++++++++- .../engine/src/generation/start_balancer.gd | 8 +++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/game/engine/src/generation/map_placer.gd b/src/game/engine/src/generation/map_placer.gd index 98b5d089..953c7d60 100644 --- a/src/game/engine/src/generation/map_placer.gd +++ b/src/game/engine/src/generation/map_placer.gd @@ -8,6 +8,7 @@ const GameMapScript = preload("res://engine/src/map/game_map.gd") const TileScript = preload("res://engine/src/map/tile.gd") const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd") const StartPositionScript = preload("res://engine/src/generation/start_position.gd") +const StartBalancerScript = preload("res://engine/src/generation/start_balancer.gd") const VillageLairPlacerScript = preload("res://engine/src/generation/village_lair_placer.gd") ## Resource placement density per land tile (base, before multiplier) @@ -37,7 +38,7 @@ func place_all( place_resources(game_map, resource_mult) var start_strategy: String = settings.get("start_strategy", "") - var start_positions: Array[Vector2i] = _start_position.select_start_positions( + var start_positions: Array[Vector2i] = _select_starts( game_map, num_players, type_data, start_strategy ) game_map.start_positions = start_positions @@ -53,6 +54,30 @@ func place_all( _village_lair_placer.place_lairs(game_map, wild_mult, start_positions) +func _select_starts( + game_map: RefCounted, num_players: int, + type_data: Dictionary, start_strategy: String, +) -> Array[Vector2i]: + ## Fairness-balanced selection (StartBalancer) for small 2-player games when + ## the map_generator.use_balanced_starts flag is set; greedy otherwise. + ## StartBalancer considers resource-adjusted yields and enforces a 0.85 + ## fairness ratio between start zones, avoiding the systemic p0-gets-resource + ## bias of the greedy scorer that ignores tile.resource_id. + if start_strategy == "": + var cfg: Dictionary = DataLoader.get_setup_entry("map_generator") + var enabled: bool = bool(cfg.get("use_balanced_starts", false)) + var max_players: int = int(cfg.get("balanced_starts_max_players", 2)) + if enabled and num_players <= max_players: + var balanced: Array[Vector2i] = StartBalancerScript.ensure_fair_starts( + game_map, num_players, type_data, _rng + ) + if balanced.size() == num_players: + return balanced + return _start_position.select_start_positions( + game_map, num_players, type_data, start_strategy + ) + + # -- Natural wonders -- diff --git a/src/game/engine/src/generation/start_balancer.gd b/src/game/engine/src/generation/start_balancer.gd index 9d8899dd..6e535556 100644 --- a/src/game/engine/src/generation/start_balancer.gd +++ b/src/game/engine/src/generation/start_balancer.gd @@ -127,18 +127,20 @@ static func ensure_fair_starts( if fallback.is_empty(): return [] + var final_ratio: float = 0.0 for _pass: int in 3: var score_a: float = score_start_zone(game_map, fallback[0]).total_value var score_b: float = score_start_zone(game_map, fallback[1]).total_value var max_score: float = maxf(score_a, score_b) - var ratio: float = minf(score_a, score_b) / max_score if max_score > 0.0 else 0.0 - if ratio >= MIN_FAIRNESS_RATIO: + final_ratio = minf(score_a, score_b) / max_score if max_score > 0.0 else 0.0 + if final_ratio >= MIN_FAIRNESS_RATIO: break var weak: Vector2i = fallback[0] if score_a < score_b else fallback[1] if not _compensate_resources(game_map, weak, rng): _compensate_terrain(game_map, weak, rng) - push_warning("StartBalancer: returning best available pair — fairness threshold not fully met") + if final_ratio < MIN_FAIRNESS_RATIO: + push_warning("StartBalancer: returning best available pair — fairness threshold not fully met") return fallback