fix(@projects/@magic-civilization): 🐛 remove city attack stack-of-doom cap

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-30 01:01:19 -04:00
parent d2c9539d49
commit a8e280c848
7 changed files with 14 additions and 29 deletions

View file

@ -43,11 +43,6 @@ var _attack_commitment_turns: int = 0
# Recomputed each turn during _play_turn, read by _next_building + rush-buy.
var _active_attack_mil_count: int = 0
var _in_attack_phase: bool = false
# Stack-of-doom cap: tracks how many times a city has been attacked this turn
# (keyed by city position string). Reset at the start of each player's turn.
# Limits pile-ons so a 10-warrior stack can't one-shot a city in a single turn.
var _city_attacks_this_turn: Dictionary = {}
const MAX_CITY_ATTACKS_PER_TURN: int = 3
# Test harness state (AUTO_PLAY_SEED path)
var _seed: int = 0
@ -948,9 +943,6 @@ func _play_turn() -> void:
if player.researching.is_empty():
_pick_research(player)
# Reset per-turn city attack counter (stack-of-doom cap).
_city_attacks_this_turn.clear()
# Refresh attack-phase signals and stack-sustain telemetry for this turn.
# _attack_commitment_turns reflects prior-turn commitment; rush-buy and
# building scoring both key off it so they respond mid-siege.
@ -2051,16 +2043,10 @@ func _try_attack_adjacent(unit: Variant, game_map: RefCounted) -> void:
for c: Variant in p.cities:
var dist: int = HexUtilsScript.hex_distance(unit.position, c.position)
if dist <= 1:
var city_key: String = "%d,%d" % [c.position.x, c.position.y]
var attacks_so_far: int = _city_attacks_this_turn.get(city_key, 0)
if attacks_so_far >= MAX_CITY_ATTACKS_PER_TURN:
# Stack-of-doom cap: don't pile on beyond the limit this turn.
return
print(" ATTACKING CITY: %s at %s -> city at %s (dist=%d)" % [unit.type_id, unit.position, c.position, dist])
var resolver_script: GDScript = load("res://engine/src/modules/combat/combat_resolver.gd")
var resolver: RefCounted = resolver_script.new()
resolver.resolve(unit, c, game_map, all_units)
_city_attacks_this_turn[city_key] = attacks_so_far + 1
unit.movement_remaining = 0
return

View file

@ -65,10 +65,6 @@ var bombard_range: int = 2
## Accumulated production toward the current production_queue[0].
var production_progress: int = 0
## Turn this city was last captured. -1 = never captured.
## Used by turn_processor to apply a production penalty during occupation.
var captured_turn: int = -1
var population: int:
get:
return get_population()

View file

@ -141,6 +141,12 @@ func process_culture(tile_yields_json: String) -> bool:
return _gd_city.call("process_culture", tile_yields_json)
func process_culture_with_modifier(tile_yields_json: String, total_pct: float) -> bool:
if _gd_city == null:
return false
return _gd_city.call("process_culture_with_modifier", tile_yields_json, total_pct)
func add_production(production: float) -> void:
if _gd_city != null:
_gd_city.call("add_production", production)

View file

@ -117,7 +117,6 @@ static func capture_city(
city.owner = attacker.owner
city.is_capital = false
city.captured_turn = GameState.turn_number
for tile_pos: Vector2i in city.owned_tiles:
var layer: Dictionary = GameState.get_primary_layer()

View file

@ -77,15 +77,8 @@ func _process_production(player: RefCounted) -> void: # Player
if tile != null and tile.biome_id == "hills":
building_prod += prod_hills
var prod_pct: float = _sum_city_building_effect_float(c, "production_percent")
# Occupation penalty: captured cities produce at 50% for 20 turns.
# Slows the attacker's production-snowball: capturing a city doesn't
# immediately double their output — they must garrison and stabilise first.
const OCCUPATION_TURNS: int = 20
var occupation_mult: float = 1.0
if c.captured_turn >= 0 and GameState.turn_number - c.captured_turn < OCCUPATION_TURNS:
occupation_mult = 0.5
var prod: int = int(
(yields.get("production", 1) + building_prod) * (1.0 + prod_pct) * prod_modifier * occupation_mult
(yields.get("production", 1) + building_prod) * (1.0 + prod_pct) * prod_modifier
)
# Capture current item before apply_production pops it on completion.
var current: Dictionary = (

View file

@ -360,8 +360,13 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
let atk_hp_factor = params.attacker.hp as f32 / params.attacker.max_hp.max(1) as f32;
// Compute damage using Civ5-style exponential formula
let strength_diff = attacker_strength - defender_strength;
let damage_to_defender =
let raw_damage_to_defender =
BASE_DAMAGE * (strength_diff / STRENGTH_DIVISOR).exp() * atk_hp_factor;
// Stack-of-doom cap: a single attack cannot deal more than 2× the defender's
// current HP. Prevents overwhelming odds from one-shotting a city or unit in
// a single exchange — multiple attackers still add up, but each hit is capped.
let damage_to_defender =
raw_damage_to_defender.min(2.0 * params.defender.hp as f32);
// Retaliation damage
let no_retaliation = prevents_retaliation(&params.attacker_keywords, is_ranged)

View file

@ -87,7 +87,7 @@
},
{
"name": "stress_first_strike_one_round_kill",
"defender_damage": 122,
"defender_damage": 40,
"attacker_damage": 0,
"attacker_outcome": "survived",
"defender_outcome": "killed",