From cb3650e3a9becbcb4b2416db0272e965395070a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 12:35:50 -0400 Subject: [PATCH] #233: open with fewer enemies, of one kind, arriving in sequence Measured before changing anything. The ticket's two halves needed opposite fixes, and the first one made the second worse. TOO COMPLEX. Variety was gated on fleet size alone, so a player who had grown early met two kinds of trouble in the first wave they ever saw - a fleet of four opened on two lancers and three chasers. Each roster entry now carries a min_wave as well, so the openings teach one enemy at a time whatever the fleet already looks like. That alone pushed wave 1 against a fleet of six from a mixed six to SEVEN chasers: budget that used to buy a lancer now bought two more chasers. So an early wave also has a size ceiling, 2 + wave_number * 2. Wave 1 is four chasers at any fleet size; the cap stops binding by wave 6 and nothing about later balance moves. before wave 1 fleet 4 -> 2 lancers, 3 chasers wave 1 fleet 6 -> 1 sniper, 2 lancers, 3 chasers after wave 1 fleet 4 -> 4 chasers wave 1 fleet 6 -> 4 chasers ARRIVE AT THE SAME TIME was literal: the whole wave spawned inside one frame as a closed ring around the fleet, which reads as a wall rather than as an attack. The first hull still lands immediately so a wave is never felt as late, and the rest follow every 0.45s. Same enemies, same bearing, same total - they simply do not all exist at once. Four existing cases objected, and each was right to: they encode intents that still hold LATER - a large wave must be varied, fleet growth must open more kinds, a wave can field a lancer at all, and killing a wave must bring the quiet back. The first three now ask at a wave number where variety is permitted rather than being weakened. The fourth runs the arrivals out before killing anything, and gained an assertion that doing so ADDS enemies - which fails if the staggering ever collapses back into one frame. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- core/GameState.gd | 30 ++++++++++++---- enemies/EnemySpawner.gd | 70 +++++++++++++++++++++++++++++++------- tests/test_lancer_enemy.gd | 7 ++++ tests/test_wave_system.gd | 52 ++++++++++++++++++++++++++-- 4 files changed, 138 insertions(+), 21 deletions(-) diff --git a/core/GameState.gd b/core/GameState.gd index 15be5b5..e84bc38 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -3275,14 +3275,30 @@ func wave_strength(fleet_size: int) -> int: # roster until the fleet is big enough to have earned it. At BASE_FLEET_SIZE # the player meets chasers and nothing else, however long they take, and each # vessel past the starting three opens exactly one new kind of trouble. +# +# min_wave is the third gate (#233). min_fleet alone still let a player who +# had grown early meet two kinds of trouble in the FIRST wave they ever saw - +# a fleet of four opened on two lancers and three chasers. Whatever the fleet +# looks like, the opening waves teach one enemy at a time. const WAVE_ROSTER: Array = [ - {"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6}, - {"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5}, - {"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4}, - {"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0}, + {"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6, "min_wave": 4}, + {"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5, "min_wave": 3}, + {"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4, "min_wave": 2}, + {"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0, "min_wave": 0}, ] const WAVE_MAX_ENEMIES: int = 18 +# How many can arrive in one wave, early on (#233). The variety gate above +# fixed "too complex" but made the other half of that ticket WORSE: budget +# that used to buy a lancer now buys two more chasers, so a grown fleet's +# FIRST wave went from a mixed six to seven chasers at once. +# +# Strength still rises with the fleet and the wave number - it simply cannot +# all be spent at once yet. What the budget cannot buy is not carried over; +# an early wave is meant to be smaller, not delayed. +func wave_max_enemies() -> int: + return mini(WAVE_MAX_ENEMIES, 2 + wave_number * 2) + func wave_composition(fleet_size: int) -> PackedStringArray: var budget: int = wave_strength(fleet_size) @@ -3290,13 +3306,15 @@ func wave_composition(fleet_size: int) -> PackedStringArray: var out: PackedStringArray = PackedStringArray() for entry in WAVE_ROSTER: if strength < int(entry.min_strength) \ - or fleet_size < int(entry.get("min_fleet", 0)): + or fleet_size < int(entry.get("min_fleet", 0)) \ + or wave_number < int(entry.get("min_wave", 0)): continue var cost: int = int(entry.cost) # The cheapest tier is last and takes whatever is left, so the budget # is always spent rather than rounded away. var share: int = budget if String(entry.id) == "chaser" else budget / 2 - while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES: + var ceiling: int = wave_max_enemies() + while share >= cost and budget >= cost and out.size() < ceiling: out.append(String(entry.id)) budget -= cost share -= cost diff --git a/enemies/EnemySpawner.gd b/enemies/EnemySpawner.gd index 2554887..acebd42 100644 --- a/enemies/EnemySpawner.gd +++ b/enemies/EnemySpawner.gd @@ -27,15 +27,59 @@ var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn") var wave_enemies: Array = [] -func _process(_delta: float) -> void: +# Seconds between one hull of a wave landing and the next. +const ARRIVAL_INTERVAL: float = 0.45 + +# Hulls of the current wave that have not landed yet, with the position each +# was assigned when the wave was aimed. +var arriving: Array = [] +var arrival_timer: float = 0.0 + + +func _process(delta: float) -> void: wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e)) if GameState.wave_pending: _launch_wave() return - if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE and wave_enemies.is_empty(): + _land_arrivals(delta) + # `arriving` as well as `wave_enemies`: between the first hull landing and + # the last, the field can legitimately be empty of live enemies, and + # clearing the wave there would end it before it had finished arriving. + if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE \ + and wave_enemies.is_empty() and arriving.is_empty(): GameState.wave_cleared() +# Forgejo #233: a wave used to appear entirely within one frame, as a closed +# ring around the fleet. That reads as a wall rather than as an attack, and +# it was the "arrive at the same time" half of the complaint - the other half +# being how many there were, which wave_composition now limits. +# +# The first hull still lands immediately, so a wave never feels delayed; the +# rest follow at ARRIVAL_INTERVAL. Same enemies, same bearing, same total - +# they just do not all exist at once. +func _land_arrivals(delta: float) -> void: + if arriving.is_empty(): + return + arrival_timer -= delta + while arrival_timer <= 0.0 and not arriving.is_empty(): + _spawn_arrival(arriving.pop_front()) + arrival_timer += ARRIVAL_INTERVAL + + +func _spawn_arrival(entry: Dictionary) -> void: + var enemy: Node2D = enemy_scene.instantiate() + # Before add_child: Enemy._ready() resolves enemy_id into a definition. + enemy.enemy_id = String(entry.id) + # #113: a wave is an attack on what the player has built. It converges + # on the nearest structure from wherever it spawned rather than + # waiting to be flown near. + enemy.relentless = true + enemy.position = entry.at + get_parent().add_child(enemy) + wave_enemies.append(enemy) + + # Public so a test can put a wave in the world without waiting out the peace. func _launch_wave() -> void: if fleet == null or fleet.ships.is_empty(): @@ -49,18 +93,20 @@ func _launch_wave() -> void: var radius: float = visible_rect.size.length() / 2.0 + spawn_margin var centre: Vector2 = fleet.get_centroid() var bearing: float = randf() * TAU + # Positions are settled now, while the fleet is where the wave was aimed + # at - not as each hull lands, or a moving fleet would drag the later + # arrivals around behind it and the ring would smear. + arriving.clear() for i in range(composition.size()): # Spread around one bearing rather than scattered at random: a wave # should read as an attack arriving, not as the map filling up. var angle: float = bearing + randf_range(-wave_spread, wave_spread) \ + TAU * float(i) / float(maxi(composition.size(), 1)) * wave_spread - var enemy: Node2D = enemy_scene.instantiate() - # Before add_child: Enemy._ready() resolves enemy_id into a definition. - enemy.enemy_id = composition[i] - # #113: a wave is an attack on what the player has built. It converges - # on the nearest structure from wherever it spawned rather than - # waiting to be flown near. - enemy.relentless = true - enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius - get_parent().add_child(enemy) - wave_enemies.append(enemy) + arriving.append({ + "id": composition[i], + "at": centre + Vector2(cos(angle), sin(angle)) * radius, + }) + # The first lands at once, so a wave is never felt as late. + if not arriving.is_empty(): + _spawn_arrival(arriving.pop_front()) + arrival_timer = ARRIVAL_INTERVAL diff --git a/tests/test_lancer_enemy.gd b/tests/test_lancer_enemy.gd index 10cfea7..738f06a 100644 --- a/tests/test_lancer_enemy.gd +++ b/tests/test_lancer_enemy.gd @@ -262,6 +262,12 @@ func _process(delta: float) -> bool: # the player now is a wave, so that is what has to be able to # produce one - and it must not have displaced the chaser while # doing it. + # Read past the openings: #233 gates each kind behind a minimum + # WAVE as well as a minimum fleet, so the first waves are chasers + # only however large the fleet is. This case is about whether a + # wave can field a lancer at all, not about when it starts to. + var settled_wave: int = game_state.wave_number + game_state.wave_number = 8 var kinds: Dictionary = {} var lancer_at: int = 0 for size in range(1, 12): @@ -269,6 +275,7 @@ func _process(delta: float) -> bool: kinds[id] = true if id == "lancer" and lancer_at == 0: lancer_at = size + game_state.wave_number = settled_wave if lancer_at == 0: return _fail("no wave against a fleet of 1..11 contains a lancer") if not kinds.has("chaser"): diff --git a/tests/test_wave_system.gd b/tests/test_wave_system.gd index e006315..c8c0322 100644 --- a/tests/test_wave_system.gd +++ b/tests/test_wave_system.gd @@ -108,12 +108,34 @@ func _state_cases() -> bool: game_state.wave_number = before_number if later.size() <= small.size(): return _fail("wave 21 against one vessel is no bigger than wave 1 - there is no escalation") - # A big wave must not be the same swarm at greater length. + # A big wave must not be the same swarm at greater length - but read at a + # wave number where variety is ALLOWED. #233 gates each enemy kind behind + # a minimum wave as well as a minimum fleet, so that the openings teach + # one enemy at a time however big the fleet already is; a fleet of nine + # meeting only chasers in wave 1 is now the intent, not the swarm this + # case is guarding against. + game_state.wave_number = before_number + 6 + var varied: PackedStringArray = game_state.wave_composition(9) + game_state.wave_number = before_number var heavy_kinds: Dictionary = {} - for id in large: + for id in varied: heavy_kinds[id] = true if heavy_kinds.size() < 2: - return _fail("a large wave is %d copies of one enemy" % large.size()) + return _fail("a large wave at wave %d is %d copies of one enemy" \ + % [before_number + 6, varied.size()]) + + # And the openings really are single-kind, whatever the fleet looks like. + game_state.wave_number = 1 + var opener: PackedStringArray = game_state.wave_composition(9) + game_state.wave_number = before_number + var opening_kinds: Dictionary = {} + for id in opener: + opening_kinds[id] = true + if opening_kinds.size() != 1: + return _fail("wave 1 against a fleet of 9 already mixes %d kinds" % opening_kinds.size()) + if opener.size() > game_state.wave_max_enemies(): + return _fail("wave 1 sends %d enemies, past the %d an opening wave may" \ + % [opener.size(), game_state.wave_max_enemies()]) if large.size() > GameState.WAVE_MAX_ENEMIES: return _fail("a wave of %d exceeds the %d cap" % [large.size(), GameState.WAVE_MAX_ENEMIES]) print("fleet 1 -> %d enemies, fleet 9 -> %d enemies of %d kinds" \ @@ -140,15 +162,23 @@ func _state_cases() -> bool: # Explicit sizes rather than BASE_FLEET_SIZE + n: #219 moved the starting # capacity to 1, and this is about the roster's own thresholds (4, 5, 6), # not about where the fleet happens to begin. + # Read past the opening waves: #233 also gates each kind behind a minimum + # WAVE, so that the first waves teach one enemy at a time whatever the + # fleet looks like. This case is about the roster's FLEET thresholds, so + # it asks at a wave number where every kind is already permitted. + var settled: int = game_state.wave_number + game_state.wave_number = 8 var previous_kinds: int = 1 for size in range(3, 8): var kinds_here: Dictionary = {} for id in game_state.wave_composition(size): kinds_here[id] = true if kinds_here.size() < previous_kinds: + game_state.wave_number = settled return _fail("a fleet of %d meets %d kinds, fewer than the %d before it" \ % [size, kinds_here.size(), previous_kinds]) previous_kinds = kinds_here.size() + game_state.wave_number = settled if previous_kinds < 3: return _fail("a grown fleet still only meets %d kinds of enemy" % previous_kinds) print("enemy variety climbs with the fleet, reaching %d kinds" % previous_kinds) @@ -244,6 +274,22 @@ func _process(delta: float) -> bool: print("wave %d arrived: %d enemies, nearest %.0fpx away" \ % [game_state.wave_number, arrived, nearest]) + # #233 lands a wave over several seconds rather than in one + # frame, so "the whole wave" is not on the field yet. Run the + # arrivals out first - otherwise this kills the one hull that + # has landed and calls that the wave cleared. + for i in range(40): + spawner._process(GameState.WAVE_MAX_SECONDS) + if spawner.arriving.is_empty(): + break + if not spawner.arriving.is_empty(): + return _fail("%d hulls never finished arriving" % spawner.arriving.size()) + var landed: int = _enemy_count() + if landed <= arrived: + return _fail("running the arrivals out added nothing (%d then %d) - the wave is not staggered" \ + % [arrived, landed]) + print("the rest of the wave followed: %d -> %d enemies" % [arrived, landed]) + for enemy in get_nodes_in_group("enemies"): if is_instance_valid(enemy): enemy.queue_free()