M1: autoritativer Drei-Lane-Kern (Karte, Kampf, Partie, Wellen, Bots)

LuneMap spiegelsymmetrisch mit 16 Türmen und 2 Ancients, LuneCombat mit
reihenfolgeunabhängigem Zweiphasenschritt und Gebäudesperren für jede
Schadensquelle, LuneMatch mit Planungsentwürfen, atomarer Übernahme,
Einmal-Belohnung und Kernsieg/Unentschieden, endlose LuneWaves, Bots über
dieselbe Befehlsschnittstelle. 87 Kernprüfungen bestanden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Martin Stickelbroeck 2026-09-27 20:57:37 +02:00
parent 4b6a4f530e
commit b565546c37
20 changed files with 1636 additions and 0 deletions

View file

@ -0,0 +1,6 @@
[gd_resource type="Resource" script_class="LuneBalance" load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/luneraria/core/lune_balance.gd" id="1"]
[resource]
script = ExtResource("1")

View file

@ -0,0 +1,69 @@
class_name LuneBalance
extends Resource
## Every tunable number of Champions of Luneraria. The default instance lives in
## data/luneraria/balance_default.tres; tests may load variants, bots never get extras.
@export_group("Simulation")
## Fixed combat step in seconds.
@export var sim_step: float = 0.05
## Ends only the current combat phase, never the match (see REGELWERK).
@export var combat_time_limit: float = 180.0
## Lane marching speed relative to the catalogue speed of a unit.
@export var march_speed_factor: float = 1.6
@export var aggro_radius: float = 7.0
## Targets further away than this are dropped and re-acquired.
@export var leash_radius: float = 10.0
@export var lane_half_width: float = 2.6
@export_group("Economy")
@export var start_gold: int = 220
@export var base_income: int = 70
@export var income_per_round: int = 4
@export var income_growth_cap: int = 80
@export var supply_cap: int = 12
@export_range(0.0, 1.0) var sell_refund: float = 0.75
## Share of lane kill gold for the responsible lane player; the rest splits evenly in the team.
@export_range(0.0, 1.0) var lane_share: float = 0.5
@export_group("Waves")
@export var wave_base_count: int = 3
## One more monster per lane every N rounds, up to wave_max_count.
@export var wave_count_every: int = 3
@export var wave_max_count: int = 7
@export var wave_hp_growth: float = 0.11
@export var wave_damage_growth: float = 0.07
@export var wave_growth_quadratic: float = 0.0015
@export var monster_bounty_scale: float = 2.0
@export var bounty_growth: float = 0.04
## Monster types in rotation order; later indices unlock later (see LuneWaves).
@export var monster_pool: PackedStringArray = PackedStringArray(["e01", "e02", "e04", "e03", "e06", "e05"])
@export_group("Buildings")
@export var tower_outer_hp: float = 6000.0
@export var tower_inner_hp: float = 7500.0
@export var base_tower_hp: float = 9000.0
@export var core_hp: float = 12000.0
@export var tower_damage: float = 90.0
@export var base_tower_damage: float = 120.0
## Must exceed the longest unit reach (6.5 m + building radius) so towers cannot be out-ranged.
@export var tower_range: float = 8.6
@export var tower_attack_interval: float = 1.0
@export var building_radius: float = 1.3
@export var bounty_outer_tower: int = 150
@export var bounty_inner_tower: int = 210
@export var bounty_base_tower: int = 270
@export_group("Map")
## Inner / outer tower position as a fraction of the lane length from the own base.
@export var inner_tower_fraction: float = 0.17
@export var outer_tower_fraction: float = 0.35
@export var tower_side_offset: float = 3.4
static func default_balance() -> LuneBalance:
var path := "res://data/luneraria/balance_default.tres"
if ResourceLoader.exists(path):
return load(path) as LuneBalance
return LuneBalance.new()
func round_income(round_number: int) -> int:
return base_income + mini(income_growth_cap, income_per_round * maxi(0, round_number - 1))

View file

@ -0,0 +1 @@
uid://daf3k13ys862

View file

@ -0,0 +1,81 @@
class_name LuneBot
extends RefCounted
## Plans through LuneMatch.submit exactly like the human: same costs, same caps, same phase.
## Reads only public state (committed buildings, round) and its own draft, never other drafts.
const PROFILES: Dictionary = {
"ausgewogen": {"front": .30, "melee": .25, "ranged": .25, "splash": .10, "support": .10, "upgrade_from": 6, "support_lane": 0.0},
"verteidigung": {"front": .40, "melee": .15, "ranged": .20, "splash": .10, "support": .15, "upgrade_from": 5, "support_lane": 0.0},
"druck": {"front": .20, "melee": .35, "ranged": .25, "splash": .15, "support": .05, "upgrade_from": 8, "support_lane": 0.0},
"fernkampf": {"front": .25, "melee": .10, "ranged": .40, "splash": .15, "support": .10, "upgrade_from": 6, "support_lane": 0.0},
"teamhilfe": {"front": .30, "melee": .20, "ranged": .25, "splash": .10, "support": .15, "upgrade_from": 6, "support_lane": .35},
}
static func profile_for(match_state: LuneMatch, slot: int) -> String:
var names: Array = PROFILES.keys()
return names[(slot * 7 + match_state.seed_value) % names.size()]
static func plan(match_state: LuneMatch, slot: int, profile_name: String = "") -> void:
var name: String = profile_name if not profile_name.is_empty() else profile_for(match_state, slot)
var profile: Dictionary = PROFILES[name]
var player: Dictionary = match_state.players[slot]
var ids: Array[String] = match_state.base_ids(player.bund)
var roles: Dictionary = {}
for id: String in ids:
roles[match_state.role_of(id)] = id
for guard: int in range(40):
var draft: Dictionary = player.draft
var roster: Array = draft.roster
var acted: bool = false
if roster.size() >= int(profile.upgrade_from) or roster.size() >= match_state.balance.supply_cap:
acted = _try_upgrade(match_state, slot, roster)
if not acted and roster.size() < match_state.balance.supply_cap:
var role: String = _needed_role(match_state, roster, profile)
var lane: String = player.lane
if float(profile.support_lane) > 0.0 and match_state.rng.randf() < float(profile.support_lane):
lane = _weakest_lane(match_state, int(player.team))
acted = match_state.submit(slot, {"type": "buy", "type_id": roles[role], "lane": lane}).ok
if not acted and roster.size() < int(profile.upgrade_from):
acted = _try_upgrade(match_state, slot, roster)
if not acted:
break
match_state.set_ready(slot)
static func _needed_role(match_state: LuneMatch, roster: Array, profile: Dictionary) -> String:
var counts: Dictionary = {}
for entry: Dictionary in roster:
var role: String = match_state.role_of(entry.type_id)
counts[role] = int(counts.get(role, 0)) + 1
var best: String = "front"
var best_gap: float = -INF
for role: String in LuneMatch.ROW_ORDER:
var gap: float = float(profile[role]) * float(roster.size() + 1) - float(counts.get(role, 0))
if gap > best_gap + 0.0001:
best = role
best_gap = gap
return best
static func _try_upgrade(match_state: LuneMatch, slot: int, roster: Array) -> bool:
var best_uid: String = ""
var best_cost: int = 1 << 30
for entry: Dictionary in roster:
var upgrade: String = str(DataCatalog.get_unit(entry.type_id).get("upgrade_id", ""))
if upgrade.is_empty():
continue
var cost: int = int(DataCatalog.get_unit(upgrade).cost)
if cost < best_cost or (cost == best_cost and str(entry.uid) < best_uid):
best_cost = cost
best_uid = str(entry.uid)
if best_uid.is_empty():
return false
return match_state.submit(slot, {"type": "upgrade", "uid": best_uid}).ok
static func _weakest_lane(match_state: LuneMatch, team: int) -> String:
var weakest: String = "mid"
var fewest: int = 99
for lane: String in LuneMap.LANES:
var towers: int = match_state.towers_alive(team, lane)
if towers < fewest:
fewest = towers
weakest = lane
return weakest

View file

@ -0,0 +1 @@
uid://dy31vyfmvjk0s

View file

@ -0,0 +1,436 @@
class_name LuneCombat
extends RefCounted
## One automatic combat phase on all three lanes. Every step first gathers targets,
## movement, hits, heals and effects from the state at the start of the step, then applies
## them together, so processing order never decides who strikes first. Deaths of a step
## resolve together; two Ancients falling in the same step is a draw.
var map: LuneMap
var balance: LuneBalance
var round_number: int = 1
var units: Array[LuneUnit] = []
var buildings: Dictionary = {}
var elapsed: float = 0.0
var steps: int = 0
var finished: bool = false
var end_reason: String = ""
var destroyed_cores: Array[int] = []
var events: Array[Dictionary] = []
var _next_id: int = 1
var _event_serial: int = 0
var _pending_damage: Dictionary = {}
var _pending_heal: Dictionary = {}
var _pending_effects: Array = []
var _pending_dots: Array = []
var _pending_shields: Array = []
func _init(match_map: LuneMap, config: LuneBalance, number: int) -> void:
map = match_map
balance = config
round_number = number
# ---------------------------------------------------------------- setup
func add_building(definition: Dictionary, hp: float) -> LuneUnit:
var unit := LuneUnit.new()
unit.id = _next_id
_next_id += 1
unit.kind = "core" if definition.tier == "core" else "tower"
unit.team = int(definition.team)
unit.lane = str(definition.lane)
unit.building_id = str(definition.id)
unit.def = {"id": definition.id, "display_name": definition.id, "max_hp": definition.max_hp, "damage": definition.damage,
"attack_range": definition.range, "attack_type": 1, "armor_type": 1, "speed": 0.0, "ability": "", "bounty": definition.bounty,
"tier": definition.tier}
unit.position = definition.position
unit.previous_position = unit.position
unit.max_hp = float(definition.max_hp)
unit.hp = clampf(hp, 0.0, unit.max_hp)
unit.alive = unit.hp > 0.0
units.append(unit)
buildings[unit.building_id] = unit
return unit
func spawn(kind: String, team: int, lane: String, definition: Dictionary, progress: float, lateral: float, slot: int = -1, uid: String = "") -> LuneUnit:
var unit := LuneUnit.new()
unit.id = _next_id
_next_id += 1
unit.kind = kind
unit.team = team
unit.lane = lane
unit.def = definition
unit.owner_slot = slot
unit.persistent_uid = uid
unit.progress = progress
unit.lateral = lateral
unit.position = map.lane_point(team, lane, progress, lateral)
unit.previous_position = unit.position
var forward: Vector2 = map.lane_forward(team, lane, progress)
unit.facing = atan2(forward.x, forward.y)
unit.max_hp = float(definition.get("max_hp", 1.0))
unit.hp = unit.max_hp
_start_ability(unit)
units.append(unit)
return unit
func alive_map() -> Dictionary:
var result: Dictionary = {}
for id: String in buildings:
result[id] = (buildings[id] as LuneUnit).alive
return result
func building_attackable(unit: LuneUnit, gates: Dictionary) -> bool:
return unit.alive and LuneMap.is_attackable(map.building_def(unit.building_id), gates)
# ---------------------------------------------------------------- step
func step() -> void:
if finished:
return
var dt: float = balance.sim_step
elapsed += dt
steps += 1
_pending_damage.clear()
_pending_heal.clear()
_pending_effects.clear()
_pending_dots.clear()
_pending_shields.clear()
var gates: Dictionary = alive_map()
var living: Array[LuneUnit] = []
for unit: LuneUnit in units:
if unit.alive:
living.append(unit)
unit.previous_position = unit.position
for unit: LuneUnit in living:
var dot: float = unit.advance_status(dt)
if dot > 0.0:
_queue_damage(unit, dot, gates)
for unit: LuneUnit in living:
if unit.is_mobile():
_ability_step(unit, living)
for unit: LuneUnit in living:
if unit.is_mobile() or (unit.kind == "tower"):
unit.target = _choose_target(unit, living, gates)
var moves: Dictionary = {}
for unit: LuneUnit in living:
if unit.is_mobile():
moves[unit.id] = _plan_move(unit, living, dt)
for unit: LuneUnit in living:
if moves.has(unit.id):
var plan: Array = moves[unit.id]
var next: Vector2 = plan[0]
var heading: Vector2 = next - unit.position
unit.position = next
if heading.length_squared() > 0.000001:
unit.facing = atan2(heading.x, heading.y)
if unit.zone == "lane":
_update_progress(unit, float(plan[1]))
for unit: LuneUnit in living:
_attack(unit, living, gates, dt)
for unit: LuneUnit in living:
if _pending_heal.has(unit.id):
unit.heal(_pending_heal[unit.id])
for entry: Array in _pending_shields:
(entry[0] as LuneUnit).grant_shield(entry[1], entry[2])
for entry: Array in _pending_effects:
(entry[0] as LuneUnit).apply_effect(entry[1], entry[2], entry[3], entry[4])
for entry: Array in _pending_dots:
(entry[0] as LuneUnit).apply_dot(entry[1], entry[2], entry[3], entry[4])
for unit: LuneUnit in living:
if _pending_damage.has(unit.id):
unit.take(_pending_damage[unit.id])
for unit: LuneUnit in living:
if unit.hp <= 0.0 and unit.alive:
unit.alive = false
unit.death_time = elapsed
unit.target = null
_record_death(unit)
_check_end()
func _check_end() -> void:
if not destroyed_cores.is_empty():
finished = true
end_reason = "core"
return
var mobile_alive: bool = false
for unit: LuneUnit in units:
if unit.alive and unit.is_mobile():
mobile_alive = true
break
if not mobile_alive:
finished = true
end_reason = "cleared"
elif elapsed >= balance.combat_time_limit - 0.000001:
finished = true
end_reason = "timeout"
func _record_death(unit: LuneUnit) -> void:
_event_serial += 1
var event: Dictionary = {"serial": _event_serial, "step": steps, "time": elapsed, "team": unit.team, "lane": unit.lane,
"zone": unit.zone, "kind": unit.kind, "unit_id": unit.id, "def_id": str(unit.def.get("id", "")), "bounty": 0,
"owner_slot": unit.owner_slot, "persistent_uid": unit.persistent_uid}
if unit.kind in ["monster", "champion", "boss"]:
event.bounty = int(unit.def.get("bounty", 0))
elif unit.is_building():
event.bounty = int(unit.def.get("bounty", 0))
event.building_id = unit.building_id
if unit.kind == "core" and not destroyed_cores.has(unit.team):
destroyed_cores.append(unit.team)
events.append(event)
# ---------------------------------------------------------------- targeting and movement
func _hostile(unit: LuneUnit, other: LuneUnit) -> bool:
return other.alive and other.team != unit.team and other.zone == unit.zone
func _choose_target(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> LuneUnit:
var current: LuneUnit = unit.target
if current != null and _hostile(unit, current) and unit.position.distance_to(current.position) <= balance.leash_radius:
if not current.is_building() or building_attackable(current, gates):
if unit.kind != "tower" or unit.position.distance_to(current.position) <= unit.attack_range():
return current
var radius: float = unit.attack_range() if unit.kind == "tower" else balance.aggro_radius
var best: LuneUnit = null
var best_distance: float = INF
for other: LuneUnit in living:
if not _hostile(unit, other) or not other.is_mobile():
continue
var d: float = unit.position.distance_to(other.position)
if d <= radius and (d < best_distance - 0.0001 or (absf(d - best_distance) <= 0.0001 and other.id < best.id)):
best = other
best_distance = d
if best != null or unit.kind == "tower":
return best
# Buildings: nearby attackable ones; at the end of a lane the whole base is in reach.
for other: LuneUnit in living:
if not other.is_building() or not _hostile(unit, other) or not building_attackable(other, gates):
continue
var d: float = unit.position.distance_to(other.position) - balance.building_radius
if (d <= radius or unit.reached_base) and d < best_distance:
best = other
best_distance = d
return best
func _march_limit(unit: LuneUnit) -> float:
var path: LuneMap.LanePath = map.lanes[unit.lane]
var limit: float = path.length
for id: String in buildings:
var building: LuneUnit = buildings[id]
if building.alive and building.kind == "tower" and building.team != unit.team and building.lane == unit.lane:
var definition: Dictionary = map.building_def(id)
var mine: float = path.length - float(definition.progress)
limit = minf(limit, mine - 1.5)
return limit
## Returns [next position, marching progress]. Marching advances the lane parameter
## directly, so lateral formation offsets cannot stall a unit on the inside of a bend.
func _plan_move(unit: LuneUnit, living: Array[LuneUnit], dt: float) -> Array:
var step_length: float = unit.speed() * balance.march_speed_factor * unit.movement_factor() * dt
var goal: Vector2 = unit.position
var travel: float = 0.0
var marched: float = unit.progress
if unit.target != null:
var reach: float = unit.attack_range() + (balance.building_radius if unit.target.is_building() else 0.0)
var gap: float = unit.position.distance_to(unit.target.position) - reach
if gap > 0.0:
goal = unit.target.position
travel = minf(step_length, gap + 0.02)
elif unit.zone == "lane":
var limit: float = _march_limit(unit)
var anchor: Vector2 = map.lane_point(unit.team, unit.lane, unit.progress, unit.lateral)
if unit.position.distance_to(anchor) > 1.2:
# Rejoin the formation line after a chase before advancing again.
goal = anchor
travel = minf(step_length, unit.position.distance_to(anchor))
elif unit.progress < limit - 0.01:
marched = minf(unit.progress + step_length, limit)
goal = map.lane_point(unit.team, unit.lane, marched, unit.lateral)
travel = unit.position.distance_to(goal)
elif unit.position.distance_to(anchor) > 0.05:
goal = anchor
travel = minf(step_length, unit.position.distance_to(anchor))
var next: Vector2 = unit.position
if travel > 0.0 and goal.distance_squared_to(unit.position) > 0.000001:
next += (goal - unit.position).normalized() * minf(travel, unit.position.distance_to(goal))
# Separation from allies so bodies do not stack; opponents may still close to melee range.
var push := Vector2.ZERO
for other: LuneUnit in living:
if other == unit or not other.is_mobile() or other.team != unit.team or other.zone != unit.zone:
continue
var offset: Vector2 = unit.position - other.position
var distance: float = offset.length()
if distance < 1.1:
if distance < 0.0001:
offset = Vector2.RIGHT if unit.id < other.id else Vector2.LEFT
distance = 0.001
push += offset.normalized() * (1.1 - distance) * 2.0
next += push.limit_length(0.9) * dt
next.x = clampf(next.x, -LuneMap.HALF_EXTENT, LuneMap.HALF_EXTENT)
next.y = clampf(next.y, -LuneMap.HALF_EXTENT, LuneMap.HALF_EXTENT)
return [next, marched]
func _update_progress(unit: LuneUnit, marched: float) -> void:
var path: LuneMap.LanePath = map.lanes[unit.lane]
var limit: float = _march_limit(unit)
if unit.target != null:
# After a chase the unit continues from where it now stands, never further back.
var blue: Vector2 = unit.position if unit.team == 0 else LuneMap.mirror(unit.position)
marched = maxf(marched, minf(path.project(blue), limit))
unit.progress = maxf(unit.progress, minf(marched, maxf(limit, unit.progress)))
if unit.progress >= path.length - 0.6:
unit.reached_base = true
# ---------------------------------------------------------------- attacks
func _attack(unit: LuneUnit, living: Array[LuneUnit], gates: Dictionary, dt: float) -> void:
unit.in_range = false
var target: LuneUnit = unit.target
if target == null or not target.alive or unit.kind == "core":
unit.attack_progress = 0.0
return
var reach: float = unit.attack_range() + (balance.building_radius if target.is_building() else 0.0) + 0.035
if unit.position.distance_to(target.position) > reach:
unit.attack_progress = 0.0
return
if target.is_building() and not building_attackable(target, gates):
unit.attack_progress = 0.0
return
unit.in_range = true
var interval: float = balance.tower_attack_interval if unit.kind == "tower" else 1.0
unit.attack_progress += dt * unit.attack_speed_factor() / interval
while unit.attack_progress >= 1.0 - 0.000001:
unit.attack_progress = maxf(0.0, unit.attack_progress - 1.0)
unit.hit_count += 1
unit.attack_serial += 1
var damage: float = float(unit.def.get("damage", 0.0)) * (1.0 + unit.damage_bonus())
if unit.hit_count % 4 == 0:
if unit.ability() == "g05":
damage *= 1.5
elif unit.ability() == "g06":
damage *= 1.75
if target.is_building():
damage *= float(unit.def.get("siege_factor", 1.0))
_queue_damage(target, target.mitigated(damage, int(unit.def.get("attack_type", 0))), gates)
_on_hit(unit, target, living, gates)
func _queue_damage(target: LuneUnit, amount: float, gates: Dictionary) -> void:
if amount <= 0.0 or not target.alive:
return
# The building gate applies to every source: ranged, splash, DoT and abilities.
if target.is_building() and not building_attackable(target, gates):
return
_pending_damage[target.id] = float(_pending_damage.get(target.id, 0.0)) + amount
func _queue_heal(target: LuneUnit, amount: float) -> void:
_pending_heal[target.id] = float(_pending_heal.get(target.id, 0.0)) + amount
# ---------------------------------------------------------------- abilities (ported from the Saumwacht rules)
func _start_ability(unit: LuneUnit) -> void:
unit.ability_ready = 6.0
match unit.ability():
"w03", "w04": unit.ability_ready = 5.0
"w09", "w10": unit.ability_ready = 4.0
"s02": unit.grant_shield(unit.max_hp * 0.15)
"e08": unit.grant_shield(unit.max_hp * 0.20)
func _nearby(unit: LuneUnit, living: Array[LuneUnit], allied: bool, center: Vector2, radius: float, maximum: int, excluded: LuneUnit = null, health_priority: bool = false) -> Array[LuneUnit]:
var result: Array[LuneUnit] = []
for other: LuneUnit in living:
if not other.alive or other == excluded or other.zone != unit.zone:
continue
if (other.team == unit.team) != allied:
continue
if allied and other.is_building():
continue
if other.position.distance_to(center) <= radius + 0.000001:
result.append(other)
result.sort_custom(func(a: LuneUnit, b: LuneUnit) -> bool:
if health_priority and not is_equal_approx(a.hp / a.max_hp, b.hp / b.max_hp):
return a.hp / a.max_hp < b.hp / b.max_hp
var ad: float = a.position.distance_to(center)
var bd: float = b.position.distance_to(center)
return a.id < b.id if is_equal_approx(ad, bd) else ad < bd)
if result.size() > maximum:
result.resize(maximum)
return result
func _ability_step(unit: LuneUnit, living: Array[LuneUnit]) -> void:
if unit.combat_time + 0.000001 < unit.ability_ready:
return
var ability: String = unit.ability()
var success: bool = false
match ability:
"w03", "w04":
if unit.hp < unit.max_hp:
_queue_heal(unit, 100.0 if ability == "w04" else 35.0)
success = true
"w09", "w10":
var upgraded: bool = ability == "w10"
for ally: LuneUnit in _nearby(unit, living, true, unit.position, 4.5 if upgraded else 4.0, 3 if upgraded else 1, null, true):
if ally.hp < ally.max_hp:
_queue_heal(ally, 80.0 if upgraded else 65.0)
success = true
"g09", "g10":
var upgraded: bool = ability == "g10"
var allies: Array[LuneUnit] = _nearby(unit, living, true, unit.position, 4.0 if upgraded else 3.0, 4 if upgraded else 3, null, true)
for ally: LuneUnit in allies:
_pending_effects.append([ally, "hearth_warmth", "damage", 0.15 if upgraded else 0.10, 4.0])
if upgraded:
_queue_heal(ally, 45.0)
success = not allies.is_empty()
"e06":
for ally: LuneUnit in _nearby(unit, living, true, unit.position, 4.0, 100, null, true):
if ally.hp < ally.max_hp:
_queue_heal(ally, 30.0 * float(unit.def.get("hp_factor", 1.0)))
success = true
break
"s03", "e09":
var victims: Array[LuneUnit] = _nearby(unit, living, false, unit.position, 6.0 if ability == "e09" else 3.0, 3 if ability == "e09" else 4)
for victim: LuneUnit in victims:
_pending_effects.append([victim, "dissonance", "attack_slow", 0.15, 3.0])
success = not victims.is_empty()
"s05":
var victims: Array[LuneUnit] = _nearby(unit, living, false, unit.position, 5.0, 1)
for victim: LuneUnit in victims:
_pending_effects.append([victim, "rift_wound", "healing_reduction", 0.30, 4.0])
success = not victims.is_empty()
if success:
unit.ability_ready = unit.combat_time + (5.0 if ability in ["w03", "w04"] else (4.0 if ability in ["w09", "w10"] else 6.0))
unit.ability_serial += 1
func _on_hit(unit: LuneUnit, primary: LuneUnit, living: Array[LuneUnit], gates: Dictionary) -> void:
var ability: String = unit.ability()
var hits: int = unit.hit_count
match ability:
"w01", "w02":
if hits % 3 == 0:
_pending_effects.append([primary, "root_grasp", "movement_slow", 0.30 if ability == "w02" else 0.20, 3.0 if ability == "w02" else 2.0])
if ability == "w02":
_pending_effects.append([primary, "root_grasp_attack", "attack_slow", 0.10, 3.0])
"w05", "w06":
if hits % 4 == 0:
_pending_dots.append([primary, "seed_burn", 10.0 if ability == "w06" else 3.0, 4.0, int(unit.def.attack_type)])
"w07", "w08":
if hits % 3 == 0:
_splash(unit, primary, living, gates, 0.80 if ability == "w08" else 0.60, 2.5 if ability == "w08" else 2.0, 4 if ability == "w08" else 3)
"g01", "g02":
if hits % 3 == 0:
_pending_dots.append([primary, "burn", 12.0 if ability == "g02" else 4.0, 3.0, int(unit.def.attack_type)])
"g07": _splash(unit, primary, living, gates, 0.30, 1.5, 3)
"g08": _splash(unit, primary, living, gates, 0.40, 2.0, 4)
"e05":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.40, 1.5, 2)
"e07":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.60, 2.0, 3)
"e10":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.60, 2.5, 4)
"s04":
if hits % 4 == 0: _splash(unit, primary, living, gates, 0.50, 2.0, 3)
func _splash(unit: LuneUnit, primary: LuneUnit, living: Array[LuneUnit], gates: Dictionary, factor: float, radius: float, maximum: int) -> void:
for victim: LuneUnit in _nearby(unit, living, false, primary.position, radius, maximum, primary):
_queue_damage(victim, victim.mitigated(float(unit.def.damage) * factor, int(unit.def.attack_type)), gates)
# ---------------------------------------------------------------- queries for views and tests
func mobile_count(team: int = -1) -> int:
var count: int = 0
for unit: LuneUnit in units:
if unit.alive and unit.is_mobile() and (team < 0 or unit.team == team):
count += 1
return count

View file

@ -0,0 +1 @@
uid://rj0ekj8y2rsa

View file

@ -0,0 +1,155 @@
class_name LuneMap
extends RefCounted
## Map data in ground coordinates Vector2(x, z): Blau (team 0) in the south-west,
## Rot (team 1) in the north-east. Rot is the mirror image of Blau across x = z
## (mirror(v) = Vector2(v.y, v.x)), Bottom is Top mirrored across the Mid line.
## Both hold by construction, so strecken, towers and spawns are functionally equal.
const LANES: Array[String] = ["top", "mid", "bot"]
const LANE_NAMES: Dictionary = {"top": "Top", "mid": "Mid", "bot": "Bottom"}
# First halves from the Blau end to the centre line; the Rot half is mirrored.
const TOP_HALF: Array[Vector2] = [Vector2(-24, 22), Vector2(-26, 6), Vector2(-26.5, -12), Vector2(-25, -25)]
const MID_HALF: Array[Vector2] = [Vector2(-21, 21), Vector2(-11, 10), Vector2(0, 0)]
const CORE_BLUE := Vector2(-30, 30)
const BASE_TOWERS_BLUE: Array[Vector2] = [Vector2(-28.8, 24.6), Vector2(-24.6, 28.8)]
## Neutral boss grotto, north-west of Mid and on the symmetry axis (M2).
const GROTTO := Vector2(-11.5, -11.5)
const HALF_EXTENT: float = 36.0
var balance: LuneBalance
var lanes: Dictionary = {}
var buildings: Array[Dictionary] = []
class LanePath:
extends RefCounted
var points: PackedVector2Array
var cumulative: PackedFloat32Array
var length: float = 0.0
func _init(source: Array[Vector2]) -> void:
points = PackedVector2Array(source)
cumulative.append(0.0)
for i: int in range(1, points.size()):
length += points[i].distance_to(points[i - 1])
cumulative.append(length)
func point_at(s: float) -> Vector2:
var d: float = clampf(s, 0.0, length)
for i: int in range(1, points.size()):
if d <= cumulative[i] or i == points.size() - 1:
var span: float = maxf(0.0001, cumulative[i] - cumulative[i - 1])
return points[i - 1].lerp(points[i], clampf((d - cumulative[i - 1]) / span, 0.0, 1.0))
return points[points.size() - 1]
func tangent_at(s: float) -> Vector2:
var d: float = clampf(s, 0.0, length)
for i: int in range(1, points.size()):
if d <= cumulative[i] or i == points.size() - 1:
return (points[i] - points[i - 1]).normalized()
return Vector2.RIGHT
func project(position: Vector2) -> float:
var best: float = INF
var best_s: float = 0.0
for i: int in range(1, points.size()):
var a: Vector2 = points[i - 1]
var b: Vector2 = points[i]
var ab: Vector2 = b - a
var t: float = clampf((position - a).dot(ab) / maxf(0.0001, ab.length_squared()), 0.0, 1.0)
var d: float = position.distance_squared_to(a + ab * t)
if d < best:
best = d
best_s = cumulative[i - 1] + ab.length() * t
return best_s
static func mirror(v: Vector2) -> Vector2:
return Vector2(v.y, v.x)
static func mirror_mid(v: Vector2) -> Vector2:
return Vector2(-v.y, -v.x)
static func full_lane(half: Array[Vector2]) -> Array[Vector2]:
var result: Array[Vector2] = half.duplicate()
for i: int in range(half.size() - 2, -1, -1):
result.append(mirror(half[i]))
return result
func _init(config: LuneBalance = null) -> void:
balance = config if config != null else LuneBalance.default_balance()
var bot_half: Array[Vector2] = []
for point: Vector2 in TOP_HALF:
bot_half.append(mirror_mid(point))
lanes["top"] = LanePath.new(full_lane(TOP_HALF))
lanes["mid"] = LanePath.new(full_lane(MID_HALF))
lanes["bot"] = LanePath.new(full_lane(bot_half))
_build_buildings()
## Lane coordinate s for a team's own marching progress p (0 at its own base).
func s_for(team: int, lane: String, progress: float) -> float:
var path: LanePath = lanes[lane]
return progress if team == 0 else path.length - progress
func progress_for(team: int, lane: String, s: float) -> float:
var path: LanePath = lanes[lane]
return s if team == 0 else path.length - s
## Blau frame point, mirrored for Rot so both sides are exact images.
func lane_point(team: int, lane: String, progress: float, lateral: float = 0.0) -> Vector2:
var path: LanePath = lanes[lane]
var s: float = clampf(progress, 0.0, path.length)
var tangent: Vector2 = path.tangent_at(s)
var blue: Vector2 = path.point_at(s) + Vector2(-tangent.y, tangent.x) * lateral
return blue if team == 0 else mirror(blue)
func lane_forward(team: int, lane: String, progress: float) -> Vector2:
var path: LanePath = lanes[lane]
var tangent: Vector2 = path.tangent_at(clampf(progress, 0.0, path.length))
return tangent if team == 0 else mirror(tangent)
func core_position(team: int) -> Vector2:
return CORE_BLUE if team == 0 else mirror(CORE_BLUE)
func _build_buildings() -> void:
buildings.clear()
for team: int in range(2):
for lane: String in LANES:
var path: LanePath = lanes[lane]
for tier: String in ["inner", "outer"]:
var fraction: float = balance.inner_tower_fraction if tier == "inner" else balance.outer_tower_fraction
var progress: float = path.length * fraction
buildings.append({"id": "%s_%s_%s" % ["blau" if team == 0 else "rot", lane, tier], "team": team, "lane": lane,
"tier": tier, "progress": progress, "position": lane_point(team, lane, progress, balance.tower_side_offset),
"max_hp": balance.tower_inner_hp if tier == "inner" else balance.tower_outer_hp,
"damage": balance.tower_damage, "range": balance.tower_range,
"bounty": balance.bounty_inner_tower if tier == "inner" else balance.bounty_outer_tower})
for index: int in range(2):
var point: Vector2 = BASE_TOWERS_BLUE[index]
buildings.append({"id": "%s_base_%d" % ["blau" if team == 0 else "rot", index + 1], "team": team, "lane": "base",
"tier": "base", "progress": -1.0, "position": point if team == 0 else mirror(point),
"max_hp": balance.base_tower_hp, "damage": balance.base_tower_damage, "range": balance.tower_range,
"bounty": balance.bounty_base_tower})
buildings.append({"id": "%s_ancient" % ("blau" if team == 0 else "rot"), "team": team, "lane": "base", "tier": "core",
"progress": -1.0, "position": core_position(team), "max_hp": balance.core_hp, "damage": 0.0, "range": 0.0, "bounty": 0})
func building_def(id: String) -> Dictionary:
for entry: Dictionary in buildings:
if entry.id == id:
return entry
return {}
## Gate rule shared by every damage source. alive maps building id -> bool.
static func is_attackable(definition: Dictionary, alive: Dictionary) -> bool:
var prefix: String = "blau" if int(definition.team) == 0 else "rot"
match str(definition.tier):
"outer":
return true
"inner":
return not alive.get("%s_%s_outer" % [prefix, definition.lane], false)
"base":
for lane: String in LANES:
if not alive.get("%s_%s_outer" % [prefix, lane], false) and not alive.get("%s_%s_inner" % [prefix, lane], false):
return true
return false
"core":
return not alive.get(prefix + "_base_1", false) and not alive.get(prefix + "_base_2", false)
return false

View file

@ -0,0 +1 @@
uid://bqnx0kaccktd5

View file

@ -0,0 +1,325 @@
class_name LuneMatch
extends RefCounted
## Authoritative match: six player slots in two teams, persistent ownership, planning
## drafts that are validated and committed atomically, combat, settlement and victory.
## A match ends only when an Ancient falls. There is no round limit and no HP decision.
enum Phase { PLANNING, COMBAT, OVER }
const ONGOING := -2
const DRAW := -1
const BOT_NAMES: Array[String] = ["Eichrufer", "Aschenzunge", "Quellwächter", "Glutfaust", "Moosauge", "Basaltherz", "Rindenkrone", "Funkenschritt"]
const ROLES: Dictionary = {"g01": "melee", "g03": "front", "g05": "ranged", "g07": "splash", "g09": "support",
"w01": "melee", "w03": "front", "w05": "ranged", "w07": "splash", "w09": "support"}
const ROW_ORDER: Array[String] = ["front", "melee", "splash", "ranged", "support"]
var balance: LuneBalance
var map: LuneMap
var rng := RandomNumberGenerator.new()
var seed_value: int = 0
var round_number: int = 1
var phase: int = Phase.PLANNING
var winner: int = ONGOING
var end_note: String = ""
var players: Array[Dictionary] = []
var building_hp: Dictionary = {}
var combat: LuneCombat
var last_settlement: Dictionary = {}
var history: Array[Dictionary] = []
var _next_uid: int = 1
var _reward_rotation: int = 0
func _init(config: LuneBalance = null) -> void:
balance = config if config != null else LuneBalance.default_balance()
map = LuneMap.new(balance)
## options: seed, human_slot (-1 for six bots), bunds (Array of six), names.
func setup(options: Dictionary = {}) -> void:
seed_value = int(options.get("seed", 1))
rng.seed = seed_value
round_number = 1
phase = Phase.PLANNING
winner = ONGOING
end_note = ""
history.clear()
last_settlement = {}
_next_uid = 1
_reward_rotation = 0
players.clear()
var human: int = int(options.get("human_slot", 1))
var bunds: Array = options.get("bunds", [])
for slot: int in range(6):
var bund: String = str(bunds[slot]) if slot < bunds.size() else ("glutpakt" if (slot + seed_value) % 2 == 0 else "wurzelbund")
var player := {"slot": slot, "team": slot / 3, "lane": LuneMap.LANES[slot % 3], "is_human": slot == human,
"name": "Du" if slot == human else BOT_NAMES[(slot + seed_value) % BOT_NAMES.size()], "bund": bund,
"gold": balance.start_gold, "roster": [], "ready": slot != human, "earned": 0, "spent": 0}
player.draft = _snapshot(player)
player.undo = []
players.append(player)
building_hp.clear()
for definition: Dictionary in map.buildings:
building_hp[definition.id] = float(definition.max_hp)
func _snapshot(player: Dictionary) -> Dictionary:
return {"gold": int(player.gold), "roster": (player.roster as Array).duplicate(true)}
func slot_of(team: int, lane: String) -> int:
return team * 3 + LuneMap.LANES.find(lane)
func base_ids(bund: String) -> Array[String]:
return DataCatalog.faction_ids(bund)
# ---------------------------------------------------------------- planning commands
func submit(slot: int, command: Dictionary) -> Dictionary:
if phase != Phase.PLANNING:
return _result(false, "Befehle sind nur in der Planung möglich.")
if slot < 0 or slot >= players.size():
return _result(false, "Unbekannter Spieler.")
var player: Dictionary = players[slot]
var draft: Dictionary = player.draft
var before: Dictionary = draft.duplicate(true)
var result: Dictionary = _apply(player, draft, command)
if result.ok:
(player.undo as Array).append(before)
return result
func undo(slot: int) -> Dictionary:
var player: Dictionary = players[slot]
if phase != Phase.PLANNING or (player.undo as Array).is_empty():
return _result(false, "Nichts rückgängig zu machen.")
player.draft = (player.undo as Array).pop_back()
return _result(true, "Letzte Planung zurückgenommen.")
func _find(roster: Array, uid: String) -> int:
for index: int in range(roster.size()):
if str(roster[index].uid) == uid:
return index
return -1
func _apply(player: Dictionary, draft: Dictionary, command: Dictionary) -> Dictionary:
var roster: Array = draft.roster
match str(command.get("type", "")):
"buy":
var type_id: String = str(command.get("type_id", ""))
if not base_ids(player.bund).has(type_id):
return _result(false, "Diese Truppe gehört nicht zu deinem Bund.")
if roster.size() >= balance.supply_cap:
return _result(false, "Versorgungsgrenze erreicht (%d Truppen)." % balance.supply_cap)
var cost: int = int(DataCatalog.get_unit(type_id).cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
var lane: String = str(command.get("lane", player.lane))
if not LuneMap.LANES.has(lane):
return _result(false, "Unbekannte Lane.")
draft.gold = int(draft.gold) - cost
roster.append({"uid": "p%d-%d" % [player.slot, _next_uid], "type_id": type_id, "lane": lane, "invested": cost, "fresh": true})
_next_uid += 1
return _result(true, "%s gekauft." % DataCatalog.get_unit(type_id).display_name)
"upgrade":
var index: int = _find(roster, str(command.get("uid", "")))
if index < 0:
return _result(false, "Diese Truppe gehört dir nicht.")
var entry: Dictionary = roster[index]
var upgrade_id: String = str(DataCatalog.get_unit(entry.type_id).get("upgrade_id", ""))
if upgrade_id.is_empty():
return _result(false, "Bereits vollständig aufgewertet.")
var cost: int = int(DataCatalog.get_unit(upgrade_id).cost)
if int(draft.gold) < cost:
return _result(false, "Es fehlen %d Gold." % (cost - int(draft.gold)))
draft.gold = int(draft.gold) - cost
entry.type_id = upgrade_id
entry.invested = int(entry.invested) + cost
return _result(true, "Zu %s aufgewertet." % DataCatalog.get_unit(upgrade_id).display_name)
"sell":
var index: int = _find(roster, str(command.get("uid", "")))
if index < 0:
return _result(false, "Diese Truppe gehört dir nicht.")
var entry: Dictionary = roster[index]
# A purchase from this planning is cancelled at full price; committed troops sell at the refund rate.
var refund: int = int(entry.invested) if bool(entry.get("fresh", false)) else floori(float(entry.invested) * balance.sell_refund)
draft.gold = int(draft.gold) + refund
roster.remove_at(index)
return _result(true, "Für %d Gold zurückgegeben." % refund)
"assign":
var index: int = _find(roster, str(command.get("uid", "")))
var lane: String = str(command.get("lane", ""))
if index < 0 or not LuneMap.LANES.has(lane):
return _result(false, "Ungültige Zuweisung.")
roster[index].lane = lane
return _result(true, "Auf %s eingeteilt." % LuneMap.LANE_NAMES[lane])
return _result(false, "Unbekannter Befehl.")
func _result(ok: bool, message: String) -> Dictionary:
return {"ok": ok, "message": message}
func set_ready(slot: int, value: bool = true) -> void:
if phase == Phase.PLANNING:
players[slot].ready = value
func all_ready() -> bool:
for player: Dictionary in players:
if not bool(player.ready):
return false
return true
# ---------------------------------------------------------------- phase changes
## Validates every draft again and commits all of them at once, then starts combat.
func begin_combat() -> bool:
if phase != Phase.PLANNING:
return false
for player: Dictionary in players:
if not _draft_valid(player):
player.draft = _snapshot(player)
for player: Dictionary in players:
player.spent = int(player.spent) + maxi(0, int(player.gold) - int(player.draft.gold))
player.gold = int(player.draft.gold)
player.roster = (player.draft.roster as Array).duplicate(true)
for entry: Dictionary in player.roster:
entry.erase("fresh")
player.undo = []
combat = LuneCombat.new(map, balance, round_number)
for definition: Dictionary in map.buildings:
if float(building_hp[definition.id]) > 0.0:
combat.add_building(definition, float(building_hp[definition.id]))
_spawn_waves()
_spawn_armies()
phase = Phase.COMBAT
return true
func _draft_valid(player: Dictionary) -> bool:
var draft: Dictionary = player.draft
if int(draft.gold) < 0 or (draft.roster as Array).size() > balance.supply_cap:
return false
var allowed: Array[String] = base_ids(player.bund)
for entry: Dictionary in draft.roster:
var id: String = str(entry.type_id)
var base: String = id
for candidate: String in allowed:
if str(DataCatalog.get_unit(candidate).get("upgrade_id", "")) == id:
base = candidate
if not allowed.has(base) or not LuneMap.LANES.has(str(entry.lane)):
return false
return true
func _spawn_waves() -> void:
var wave: Array[Dictionary] = LuneWaves.build(balance, round_number)
for team: int in range(2):
for lane: String in LuneMap.LANES:
for index: int in range(wave.size()):
var row: int = index / 3
var lateral: float = (float(index % 3) - 1.0) * 1.3
combat.spawn("monster", team, lane, wave[index].duplicate(true), 11.0 - float(row) * 1.4, lateral)
func _spawn_armies() -> void:
for team: int in range(2):
for lane: String in LuneMap.LANES:
# The lane owner's troops form the first rows; supporting players follow behind.
var owner: int = slot_of(team, lane)
var ordered: Array = []
for slot: int in [owner] + [team * 3, team * 3 + 1, team * 3 + 2].filter(func(s: int) -> bool: return s != owner):
var entries: Array = (players[slot].roster as Array).filter(func(e: Dictionary) -> bool: return e.lane == lane)
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var ra: int = ROW_ORDER.find(role_of(a.type_id))
var rb: int = ROW_ORDER.find(role_of(b.type_id))
return str(a.uid) < str(b.uid) if ra == rb else ra < rb)
for entry: Dictionary in entries:
ordered.append([slot, entry])
for index: int in range(ordered.size()):
var slot: int = ordered[index][0]
var entry: Dictionary = ordered[index][1]
var row: int = index / 4
var lateral: float = (float(index % 4) - 1.5) * 1.2
var definition: Dictionary = DataCatalog.get_unit(entry.type_id)
combat.spawn("troop", team, lane, definition, maxf(0.4, 7.2 - float(row) * 1.5), lateral, slot, str(entry.uid))
func role_of(type_id: String) -> String:
var id: String = type_id
for base: String in ROLES:
if base == id or str(DataCatalog.get_unit(base).get("upgrade_id", "")) == id:
return ROLES[base]
return "melee"
func step_combat() -> void:
if phase != Phase.COMBAT or combat == null:
return
combat.step()
if combat.finished:
_settle()
## Runs the current combat phase to its end (tests, bots-only simulations).
func finish_combat() -> void:
while phase == Phase.COMBAT:
step_combat()
func _settle() -> void:
var gains: Array[int] = [0, 0, 0, 0, 0, 0]
var lane_kills: Dictionary = {}
var buildings_lost: Array[String] = []
for event: Dictionary in combat.events:
var dying: int = int(event.team)
var killer: int = 1 - dying
var bounty: int = int(event.bounty)
if event.kind in ["monster", "champion", "boss"] and bounty > 0 and event.zone == "lane":
var owner: int = slot_of(killer, str(event.lane))
var lane_part: int = int(floor(float(bounty) * balance.lane_share))
gains[owner] += lane_part
_split(gains, killer, bounty - lane_part)
lane_kills[event.lane] = int(lane_kills.get(event.lane, 0)) + 1
elif event.kind in ["tower", "core"]:
buildings_lost.append(str(event.building_id))
_split(gains, killer, bounty)
for unit: LuneUnit in combat.units:
if unit.is_building():
building_hp[unit.building_id] = unit.hp if unit.alive else 0.0
var income: int = balance.round_income(round_number)
for player: Dictionary in players:
var total: int = gains[player.slot] + (0 if not combat.destroyed_cores.is_empty() else income)
player.gold = int(player.gold) + total
player.earned = int(player.earned) + total
last_settlement = {"round": round_number, "gains": gains, "income": income, "buildings_lost": buildings_lost,
"end_reason": combat.end_reason, "combat_time": combat.elapsed, "lane_kills": lane_kills}
history.append({"round": round_number, "end_reason": combat.end_reason, "combat_time": combat.elapsed,
"buildings": _alive_count(), "gold": players.map(func(p: Dictionary) -> int: return int(p.gold)),
"lost": buildings_lost.duplicate()})
if not combat.destroyed_cores.is_empty():
winner = DRAW if combat.destroyed_cores.size() >= 2 else 1 - combat.destroyed_cores[0]
end_note = "Beide Ancients fielen im selben Schritt." if winner == DRAW else "Ancient von %s zerstört." % ("Blau" if combat.destroyed_cores[0] == 0 else "Rot")
phase = Phase.OVER
return
round_number += 1
phase = Phase.PLANNING
for player: Dictionary in players:
player.draft = _snapshot(player)
player.undo = []
player.ready = not bool(player.is_human)
## Distributes an amount evenly over a team; the remainder rotates so no slot is favoured.
func _split(gains: Array[int], team: int, amount: int) -> void:
if amount <= 0:
return
var share: int = amount / 3
var rest: int = amount % 3
for offset: int in range(3):
gains[team * 3 + offset] += share
for index: int in range(rest):
gains[team * 3 + (_reward_rotation + index) % 3] += 1
_reward_rotation = (_reward_rotation + rest) % 3
func _alive_count() -> Array[int]:
var counts: Array[int] = [0, 0]
for id: String in building_hp:
if float(building_hp[id]) > 0.0:
counts[0 if id.begins_with("blau") else 1] += 1
return counts
func building_alive(id: String) -> bool:
return float(building_hp.get(id, 0.0)) > 0.0
func towers_alive(team: int, lane: String) -> int:
var prefix: String = "blau" if team == 0 else "rot"
return int(building_alive("%s_%s_outer" % [prefix, lane])) + int(building_alive("%s_%s_inner" % [prefix, lane]))
func core_hp(team: int) -> float:
return float(building_hp["blau_ancient" if team == 0 else "rot_ancient"])
func gold_of(slot: int) -> int:
return int(players[slot].draft.gold) if phase == Phase.PLANNING else int(players[slot].gold)

View file

@ -0,0 +1 @@
uid://dgn4ftbspq233

View file

@ -0,0 +1,161 @@
class_name LuneUnit
extends RefCounted
## One combat instance. Persistent ownership stays in LuneMatch; this object lives for
## one combat phase. Damage, heals and effects are queued and applied by LuneCombat.
const DAMAGE_MATRIX = [[1.30, 0.75, 1.00], [1.00, 1.30, 0.75], [0.75, 1.00, 1.30]]
const MOBILE_KINDS: Array[String] = ["troop", "hero", "monster", "champion", "boss"]
var id: int = 0
var kind: String = "troop"
var team: int = 0
var owner_slot: int = -1
var persistent_uid: String = ""
var lane: String = "mid"
var zone: String = "lane"
var def: Dictionary = {}
var building_id: String = ""
var position: Vector2 = Vector2.ZERO
var previous_position: Vector2 = Vector2.ZERO
var facing: float = 0.0
var hp: float = 1.0
var max_hp: float = 1.0
var alive: bool = true
var target: LuneUnit = null
var progress: float = 0.0
var lateral: float = 0.0
var attack_progress: float = 0.0
var hit_count: int = 0
var combat_time: float = 0.0
var shield: float = 0.0
var shield_remaining: float = INF
var effects: Dictionary = {}
var dots: Dictionary = {}
var ability_ready: float = 6.0
var reached_base: bool = false
# Presentation-only signals; never read by the rules.
var attack_serial: int = 0
var ability_serial: int = 0
var hit_flash: float = 0.0
var in_range: bool = false
var death_time: float = -1.0
func is_building() -> bool:
return kind == "tower" or kind == "core"
func is_mobile() -> bool:
return kind in MOBILE_KINDS
func attack_range() -> float:
return float(def.get("attack_range", 1.0))
func speed() -> float:
return float(def.get("speed", 2.0))
func armor_type() -> int:
return clampi(int(def.get("armor_type", 1 if is_building() else 0)), 0, 2)
func ability() -> String:
return str(def.get("ability", ""))
func is_boss() -> bool:
return kind == "boss" or bool(def.get("is_boss", false))
func mitigated(raw: float, attack_type: int) -> float:
var value: float = maxf(0.0, raw) * float(DAMAGE_MATRIX[clampi(attack_type, 0, 2)][armor_type()])
return value * (1.0 - damage_reduction())
## Applies an already mitigated amount through the shield.
func take(amount: float) -> void:
if not alive or amount <= 0.0:
return
var absorbed: float = minf(shield, amount)
shield -= absorbed
hp = maxf(0.0, hp - (amount - absorbed))
hit_flash = .18
func heal(amount: float) -> void:
if alive and not is_building():
hp = minf(max_hp, hp + maxf(0.0, amount) * (1.0 - strongest_effect("healing_reduction")))
func grant_shield(amount: float, duration: float = INF) -> void:
if alive and amount > shield:
shield = amount
shield_remaining = duration
func apply_effect(family: String, effect_kind: String, amount: float, duration: float) -> void:
# Buildings ignore crowd control and buffs; their gates are handled elsewhere.
if not alive or is_building():
return
if effects.has(family):
effects[family].amount = maxf(float(effects[family].amount), amount)
effects[family].remaining = maxf(float(effects[family].remaining), duration)
else:
effects[family] = {"kind": effect_kind, "amount": amount, "remaining": duration}
func apply_dot(family: String, damage: float, duration: float, attack_type: int) -> void:
if not alive or is_building():
return
if dots.has(family):
var effect: Dictionary = dots[family]
if damage > float(effect.damage):
effect.damage = damage
effect.attack_type = attack_type
effect.remaining = maxf(float(effect.remaining), duration)
else:
dots[family] = {"damage": damage, "remaining": duration, "next_tick": 1.0, "attack_type": attack_type}
func strongest_effect(effect_kind: String) -> float:
var amount: float = 0.0
for effect: Dictionary in effects.values():
if str(effect.kind) == effect_kind:
amount = maxf(amount, float(effect.amount))
return amount
func damage_bonus() -> float:
var result: float = 0.0
for effect: Dictionary in effects.values():
if str(effect.kind) == "damage":
result += float(effect.amount)
return minf(0.30, result) + float(def.get("damage_bonus", 0.0))
func damage_reduction() -> float:
var result: float = strongest_effect("reduction")
match ability():
"g03":
if combat_time < 8.0 - 0.000001:
result = maxf(result, 0.15)
"g04":
result = maxf(result, 0.25 if combat_time < 10.0 - 0.000001 else 0.10)
return minf(0.35, result + float(def.get("reduction_bonus", 0.0)))
func attack_speed_factor() -> float:
var slowing: float = strongest_effect("attack_slow") * (0.5 if is_boss() else 1.0)
return 1.0 + clampf(strongest_effect("attack_speed") - slowing, -0.25, 0.25)
func movement_factor() -> float:
return 1.0 - minf(0.35, strongest_effect("movement_slow") * (0.5 if is_boss() else 1.0))
## Status timers; DoT damage is returned so the caller can queue it with all other damage.
func advance_status(delta: float) -> float:
combat_time += delta
hit_flash = maxf(0.0, hit_flash - delta)
for family: String in effects.keys():
effects[family].remaining -= delta
if float(effects[family].remaining) <= 0.000001:
effects.erase(family)
if shield_remaining != INF:
shield_remaining -= delta
if shield_remaining <= 0.000001:
shield = 0.0
var dot_damage: float = 0.0
for family: String in dots.keys():
var effect: Dictionary = dots[family]
effect.next_tick -= minf(delta, maxf(0.0, float(effect.remaining)))
while float(effect.next_tick) <= 0.000001:
dot_damage += mitigated(float(effect.damage), int(effect.attack_type))
effect.next_tick += 1.0
effect.remaining -= delta
if float(effect.remaining) <= 0.000001:
dots.erase(family)
return dot_damage

View file

@ -0,0 +1 @@
uid://3658nsy6hfo

View file

@ -0,0 +1,38 @@
class_name LuneWaves
extends RefCounted
## Endless monster waves. Both teams receive the identical composition per lane, so the
## waves never favour a side. Count is capped; strength grows through stats and later types.
const MELEE: Array[String] = ["e01", "e02", "e05"]
static func strength(balance: LuneBalance, round_number: int, growth: float) -> float:
var r: float = float(maxi(1, round_number) - 1)
return 1.0 + growth * r + balance.wave_growth_quadratic * r * r
static func monster_count(balance: LuneBalance, round_number: int) -> int:
var extra: int = (maxi(1, round_number) - 1) / maxi(1, balance.wave_count_every)
return mini(balance.wave_max_count, balance.wave_base_count + extra)
static func unlocked_types(balance: LuneBalance, round_number: int) -> PackedStringArray:
var pool: PackedStringArray = balance.monster_pool
var available: int = clampi(1 + (maxi(1, round_number) - 1) / 2, 1, pool.size())
return pool.slice(0, available)
## Definitions for one lane of one team, melee first so they lead the march.
static func build(balance: LuneBalance, round_number: int) -> Array[Dictionary]:
var types: PackedStringArray = unlocked_types(balance, round_number)
var hp_factor: float = strength(balance, round_number, balance.wave_hp_growth)
var damage_factor: float = strength(balance, round_number, balance.wave_damage_growth)
var bounty_factor: float = balance.monster_bounty_scale * (1.0 + balance.bounty_growth * float(maxi(1, round_number) - 1))
var melee: Array[Dictionary] = []
var ranged: Array[Dictionary] = []
for index: int in range(monster_count(balance, round_number)):
var type_id: String = types[(index + round_number) % types.size()]
var source: Dictionary = DataCatalog.ENEMIES[type_id]
var monster: Dictionary = source.duplicate(true)
monster.max_hp = float(source.max_hp) * hp_factor
monster.damage = float(source.damage) * damage_factor
monster.hp_factor = hp_factor
monster.bounty = int(clampf(roundf(float(source.bounty) * bounty_factor), 1.0, 1.0e12))
(melee if type_id in MELEE else ranged).append(monster)
return melee + ranged

View file

@ -0,0 +1 @@
uid://c1fsheercsrib

View file

@ -0,0 +1,297 @@
extends Node
## Rules tests for the Champions of Luneraria core (no rendering). Legacy Saumwacht tests
## stay in tests/test_runner.gd. Run: godot --headless --path . res://tests/luneraria/test_lune_core.tscn
var checks: int = 0
var failures: Array[String] = []
func check(ok: bool, label: String) -> void:
checks += 1
if not ok:
failures.append(label)
printerr("LUNE_FAIL: ", label)
func _ready() -> void:
call_deferred("run")
func new_match(seed: int = 7, human: int = -1) -> LuneMatch:
var state := LuneMatch.new()
state.setup({"seed": seed, "human_slot": human, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]})
return state
func run() -> void:
_test_structure()
_test_symmetry()
_test_gates()
_test_victory_and_draw()
_test_endless_waves()
_test_commands_and_ownership()
_test_rewards()
_test_persistence_and_restart()
_test_full_match()
var report := {"checks": checks, "failures": failures}
var file := FileAccess.open("res://docs/luneraria/logs/core-tests.json", FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
print("LUNE_CORE_%s: %d checks; %d failures" % ["PASS" if failures.is_empty() else "FAIL", checks, failures.size()])
get_tree().quit(0 if failures.is_empty() else 1)
func _test_structure() -> void:
var state := new_match()
check(state.players.size() == 6, "six player slots")
var teams: Dictionary = {}
var roles: Dictionary = {}
for player: Dictionary in state.players:
teams[player.team] = int(teams.get(player.team, 0)) + 1
roles["%d-%s" % [player.team, player.lane]] = true
check(teams.size() == 2 and teams[0] == 3 and teams[1] == 3, "exactly two teams of three")
check(roles.size() == 6, "six distinct owner roles (team x lane)")
check(LuneMap.LANES == ["top", "mid", "bot"] and state.map.lanes.size() == 3, "three lanes")
var towers: int = 0
var cores: int = 0
var per_team_lane: Dictionary = {}
var base_towers: Array[int] = [0, 0]
for definition: Dictionary in state.map.buildings:
if definition.tier == "core":
cores += 1
else:
towers += 1
if definition.tier == "base":
base_towers[definition.team] += 1
else:
per_team_lane["%d-%s" % [definition.team, definition.lane]] = int(per_team_lane.get("%d-%s" % [definition.team, definition.lane], 0)) + 1
check(towers == 16 and cores == 2, "sixteen towers and two Ancients")
check(per_team_lane.size() == 6 and per_team_lane.values().all(func(v: int) -> bool: return v == 2), "two lane towers per team and lane")
check(base_towers == [2, 2], "two base towers per team")
func _test_symmetry() -> void:
var map := LuneMap.new()
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[lane]
var worst: float = 0.0
for i: int in range(21):
var s: float = path.length * float(i) / 20.0
worst = maxf(worst, LuneMap.mirror(path.point_at(s)).distance_to(path.point_at(path.length - s)))
check(worst < 0.001, "%s lane is its own mirror image (team fairness)" % lane)
check(absf(map.lanes["top"].length - map.lanes["bot"].length) < 0.001, "top and bottom lanes have equal length")
for definition: Dictionary in map.buildings:
if definition.team == 0:
var twin: Dictionary = map.building_def(str(definition.id).replace("blau", "rot"))
check(LuneMap.mirror(definition.position).distance_to(twin.position) < 0.001 and is_equal_approx(float(definition.max_hp), float(twin.max_hp)), "mirrored building " + str(definition.id))
for lane: String in LuneMap.LANES:
check(map.lane_point(1, lane, 7.0, 1.2).distance_to(LuneMap.mirror(map.lane_point(0, lane, 7.0, 1.2))) < 0.001, "mirrored spawn points on " + lane)
check(LuneMap.mirror(LuneMap.GROTTO).distance_to(LuneMap.GROTTO) < 0.001, "grotto equidistant for both teams")
var mid: LuneMap.LanePath = map.lanes["mid"]
var grotto_gap: float = mid.point_at(mid.project(LuneMap.GROTTO)).distance_to(LuneMap.GROTTO)
check(grotto_gap > 8.0, "grotto lies beside, not on, the mid lane (%.1f m)" % grotto_gap)
func _test_gates() -> void:
var map := LuneMap.new()
var alive: Dictionary = {}
for definition: Dictionary in map.buildings:
alive[definition.id] = true
var gate := func(id: String) -> bool: return LuneMap.is_attackable(map.building_def(id), alive)
check(gate.call("rot_top_outer") and not gate.call("rot_top_inner"), "outer before inner tower")
check(not gate.call("rot_base_1") and not gate.call("rot_ancient"), "base and Ancient protected at start")
alive["rot_top_outer"] = false
check(gate.call("rot_top_inner") and not gate.call("rot_base_1"), "inner opens after outer; base still closed")
alive["rot_mid_outer"] = false
alive["rot_bot_outer"] = false
check(not gate.call("rot_base_1"), "outer towers of all lanes do not open the base")
alive["rot_top_inner"] = false
check(gate.call("rot_base_1") and gate.call("rot_base_2") and not gate.call("rot_ancient"), "one broken lane opens the base, not the Ancient")
check(gate.call("rot_mid_inner") and gate.call("rot_bot_inner"), "towers of other lanes keep working and stay attackable in order")
alive["rot_base_1"] = false
check(not gate.call("rot_ancient"), "Ancient needs both base towers down")
alive["rot_base_2"] = false
check(gate.call("rot_ancient"), "Ancient vulnerable after both base towers")
# Gate applies to every damage source inside combat, including splash.
var combat := LuneCombat.new(map, LuneBalance.default_balance(), 1)
var inner: LuneUnit = combat.add_building(map.building_def("rot_top_inner"), 1000.0)
combat.add_building(map.building_def("rot_top_outer"), 1000.0)
var gates: Dictionary = combat.alive_map()
combat._queue_damage(inner, 500.0, gates)
check(not combat._pending_damage.has(inner.id), "locked inner tower ignores queued damage (ranged/splash/DoT)")
func _duel(core_hp_a: float, core_hp_b: float) -> LuneCombat:
var map := LuneMap.new()
var combat := LuneCombat.new(map, LuneBalance.default_balance(), 1)
for definition: Dictionary in map.buildings:
if definition.tier == "core":
combat.add_building(definition, core_hp_a if definition.team == 0 else core_hp_b)
else:
combat.add_building(definition, 0.0)
return combat
func _attacker(combat: LuneCombat, team: int, damage: float) -> LuneUnit:
var definition: Dictionary = DataCatalog.get_unit("g05").duplicate(true)
definition.damage = damage
var unit: LuneUnit = combat.spawn("troop", team, "mid", definition, 1.0, 0.0)
unit.position = combat.map.core_position(1 - team) + Vector2(2.5, 0).rotated(team)
unit.reached_base = true
return unit
func _test_victory_and_draw() -> void:
var combat := _duel(12000.0, 30.0)
_attacker(combat, 0, 200.0)
for i: int in range(200):
combat.step()
if combat.finished: break
check(combat.finished and combat.end_reason == "core" and combat.destroyed_cores == [1], "destroying an Ancient ends the combat with a core result")
var draw := _duel(30.0, 30.0)
_attacker(draw, 1, 200.0)
_attacker(draw, 0, 200.0)
for i: int in range(200):
draw.step()
if draw.finished: break
check(draw.destroyed_cores.size() == 2, "both Ancients in the same step are both recorded")
var state := new_match()
state.begin_combat()
state.combat.destroyed_cores = [0, 1]
state.combat.finished = true
state.combat.end_reason = "core"
state._settle()
check(state.phase == LuneMatch.Phase.OVER and state.winner == LuneMatch.DRAW, "simultaneous Ancient loss is a draw")
# Unit order must not decide: reversing the spawn order gives the same result.
var reversed := _duel(30.0, 30.0)
_attacker(reversed, 0, 200.0)
_attacker(reversed, 1, 200.0)
for i: int in range(200):
reversed.step()
if reversed.finished: break
check(reversed.destroyed_cores.size() == 2 and reversed.steps == draw.steps, "processing order gives no advantage")
var timeout := LuneMatch.new()
timeout.setup({"seed": 3, "human_slot": -1})
timeout.begin_combat()
timeout.combat.elapsed = timeout.balance.combat_time_limit
timeout.step_combat()
check(timeout.combat.end_reason == "timeout" and timeout.phase == LuneMatch.Phase.PLANNING and timeout.winner == LuneMatch.ONGOING, "combat time window ends the phase, never the match")
check(timeout.core_hp(0) == timeout.balance.core_hp and timeout.core_hp(1) == timeout.balance.core_hp, "time window causes no hidden core damage")
func _test_endless_waves() -> void:
var balance := LuneBalance.default_balance()
var previous: float = 0.0
for round_number: int in [1, 5, 8, 20, 21, 50, 200, 5000, 1000000]:
var wave: Array[Dictionary] = LuneWaves.build(balance, round_number)
var total: float = 0.0
var finite: bool = true
for monster: Dictionary in wave:
total += float(monster.max_hp) + float(monster.damage) * 10.0
finite = finite and is_finite(float(monster.max_hp)) and is_finite(float(monster.damage)) and int(monster.bounty) > 0
check(finite and wave.size() <= balance.wave_max_count and wave.size() >= 1, "wave %d finite and capped (%d monsters)" % [round_number, wave.size()])
check(total > previous, "wave %d stronger than the previous sample" % round_number)
previous = total
var state := new_match(11)
state.round_number = 21
state.begin_combat()
var monsters: int = 0
for unit: LuneUnit in state.combat.units:
if unit.kind == "monster":
monsters += 1
check(monsters == 6 * LuneWaves.monster_count(balance, 21), "round 21 spawns ordinary waves; no final wave logic")
state.finish_combat()
check(state.phase != LuneMatch.Phase.OVER or state.winner != LuneMatch.ONGOING, "no match end without a core result")
check(state.round_number == 22 or state.phase == LuneMatch.Phase.OVER, "round counter continues after 20")
func _test_commands_and_ownership() -> void:
var state := new_match(5, 1)
var gold: int = state.gold_of(1)
check(not state.submit(1, {"type": "buy", "type_id": "g01"}).ok, "buying another Bund's troop is rejected")
var bought: Dictionary = state.submit(1, {"type": "buy", "type_id": "w01"})
check(bought.ok and state.gold_of(1) == gold - 50, "buy costs exactly the catalogue price")
var uid: String = str(state.players[1].draft.roster[0].uid)
check(not state.submit(0, {"type": "upgrade", "uid": uid}).ok, "foreign unit id is rejected")
check(not state.submit(1, {"type": "upgrade", "uid": "p1-9999"}).ok, "unknown unit id is rejected")
check(state.players[1].roster.is_empty(), "draft purchases are not committed before the phase change")
check(state.submit(1, {"type": "assign", "uid": uid, "lane": "top"}).ok and not state.submit(1, {"type": "assign", "uid": uid, "lane": "grotte"}).ok, "lane assignment validated")
state.submit(1, {"type": "sell", "uid": uid})
check(state.gold_of(1) == gold, "cancelling a purchase in the same planning refunds fully")
state.submit(1, {"type": "buy", "type_id": "w01"})
state.players[1].draft.gold = -5
state.begin_combat()
check(state.players[1].roster.is_empty() and state.players[1].gold == gold, "invalid draft is rejected atomically at the phase change")
state.finish_combat()
var cap := new_match(6, 0)
cap.players[0].draft.gold = 100000
for i: int in range(cap.balance.supply_cap + 3):
cap.submit(0, {"type": "buy", "type_id": "g01"})
check(cap.players[0].draft.roster.size() == cap.balance.supply_cap, "supply cap enforced")
check(not cap.submit(0, {"type": "buy", "type_id": "g01"}).ok and cap.phase == LuneMatch.Phase.PLANNING, "no purchases beyond the cap")
cap.begin_combat()
check(not cap.submit(0, {"type": "buy", "type_id": "g03"}).ok, "no commands during combat")
var bots := new_match(9)
var before: Array = bots.players.map(func(p: Dictionary) -> int: return int(p.gold))
for slot: int in range(6):
LuneBot.plan(bots, slot)
var spent_ok: bool = true
for slot: int in range(6):
var invested: int = 0
for entry: Dictionary in bots.players[slot].draft.roster:
invested += int(entry.invested)
spent_ok = spent_ok and invested + int(bots.players[slot].draft.gold) == int(before[slot])
check(spent_ok, "bots spend exactly their own gold, no extra resources")
func _test_rewards() -> void:
var state := new_match(4)
state.begin_combat()
var combat: LuneCombat = state.combat
combat.events.clear()
combat.events.append({"serial": 1, "team": 1, "lane": "top", "zone": "lane", "kind": "monster", "bounty": 101})
combat.events.append({"serial": 2, "team": 1, "lane": "top", "zone": "lane", "kind": "monster", "bounty": 101})
combat.events.append({"serial": 3, "team": 0, "lane": "mid", "zone": "lane", "kind": "tower", "bounty": 151, "building_id": "blau_mid_outer"})
combat.events.append({"serial": 4, "team": 0, "lane": "bot", "zone": "lane", "kind": "troop", "bounty": 0})
combat.finished = true
combat.end_reason = "cleared"
var gold_before: Array = state.players.map(func(p: Dictionary) -> int: return int(p.gold))
state._settle()
var gains: Array = state.last_settlement.gains
check(gains[0] + gains[1] + gains[2] == 202, "each monster death pays exactly once, total 2 x 101 to Blau")
check(gains[0] >= 50 + 50 and gains[0] - gains[1] >= 90, "Top lane player of the killing team gets the lane share")
check(gains[3] + gains[4] + gains[5] == 151, "tower bounty exactly once to Rot")
check(abs(gains[3] - gains[4]) <= 1 and abs(gains[4] - gains[5]) <= 1, "tower bounty split evenly with integer remainder")
var income: int = state.last_settlement.income
var paid: bool = true
for slot: int in range(6):
paid = paid and int(state.players[slot].gold) == int(gold_before[slot]) + gains[slot] + income
check(paid, "settlement credits gains plus income, nothing else")
check(gains[3] + gains[4] + gains[5] + gains[0] + gains[1] + gains[2] == 353, "persistent troop deaths pay nothing")
func _test_persistence_and_restart() -> void:
var state := new_match(8, 0)
state.submit(0, {"type": "buy", "type_id": "g03"})
state.set_ready(0)
var uid: String = str(state.players[0].draft.roster[0].uid)
state.begin_combat()
state.combat.buildings["blau_mid_outer"].hp -= 1234.0
var fallen: LuneUnit = null
for unit: LuneUnit in state.combat.units:
if unit.persistent_uid == uid:
fallen = unit
fallen.hp = 0.0
fallen.alive = false
state.finish_combat()
check(state.phase == LuneMatch.Phase.PLANNING and state.round_number == 2, "round advances to planning")
check(state.players[0].roster.size() == 1 and state.players[0].roster[0].uid == uid, "fallen persistent troop returns to the roster")
check(is_equal_approx(state.building_hp["blau_mid_outer"], state.balance.tower_outer_hp - 1234.0) or state.building_hp["blau_mid_outer"] < state.balance.tower_outer_hp - 1234.0, "building damage persists between rounds")
state.begin_combat()
var healed: bool = false
for unit: LuneUnit in state.combat.units:
if unit.persistent_uid == uid:
healed = unit.hp == unit.max_hp and unit.effects.is_empty() and unit.dots.is_empty()
check(healed, "returning troop starts the next combat healed and without effects")
state.finish_combat()
var fresh := new_match(8, 0)
check(fresh.players[0].roster.is_empty() and fresh.round_number == 1 and fresh.building_hp["blau_mid_outer"] == fresh.balance.tower_outer_hp, "new match starts without previous progress")
func _test_full_match() -> void:
var state := new_match(2)
var limit: int = 80
while state.phase != LuneMatch.Phase.OVER and state.round_number <= limit:
for slot: int in range(6):
LuneBot.plan(state, slot)
state.begin_combat()
state.finish_combat()
check(state.phase == LuneMatch.Phase.OVER, "a six-bot match reaches a regular Ancient result (seed 2)")
check(state.winner in [0, 1, LuneMatch.DRAW], "result is Blau, Rot or draw")
var cores_down: int = int(state.core_hp(0) <= 0.0) + int(state.core_hp(1) <= 0.0)
check(cores_down >= 1, "the result follows an actually destroyed Ancient")
print("LUNE_FULL_MATCH: seed 2 -> %s after %d rounds" % ["draw" if state.winner == LuneMatch.DRAW else ("Blau" if state.winner == 0 else "Rot"), state.history.size()])

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/luneraria/test_lune_core.gd" id="1"]
[node name="LuneCoreTests" type="Node"]
script = ExtResource("1")

View file

@ -0,0 +1,48 @@
extends Node
## Headless six-bot matches for balance and acceptance. A QA round limit is reported as
## "Testlimit erreicht / Partie nicht abgeschlossen", never as a result.
## Run: godot --headless --path . res://tools/luneraria/sim_match.tscn -- --seeds=1,2,3 --limit=60
func _ready() -> void:
call_deferred("run")
func run() -> void:
var seeds: Array = [1]
var limit: int = 60
var out: String = ""
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--seeds="):
seeds = Array(arg.substr(8).split(",")).map(func(s: String) -> int: return int(s))
elif arg.begins_with("--limit="):
limit = int(arg.substr(8))
elif arg.begins_with("--out="):
out = arg.substr(6)
var reports: Array = []
for seed: int in seeds:
var started: int = Time.get_ticks_msec()
var match_state := LuneMatch.new()
match_state.setup({"seed": seed, "human_slot": -1})
while match_state.phase != LuneMatch.Phase.OVER and match_state.round_number <= limit:
for slot: int in range(6):
LuneBot.plan(match_state, slot)
match_state.begin_combat()
var combat: LuneCombat = match_state.combat
match_state.finish_combat()
if combat.end_reason == "timeout" and "--diagnose" in OS.get_cmdline_user_args():
for unit: LuneUnit in combat.units:
if unit.alive and unit.is_mobile():
print(" STUCK t%d %s %s %s pos=%s prog=%.1f base=%s target=%s hp=%.0f/%.0f" % [unit.team, unit.lane, unit.kind, unit.def.get("id", ""), str(unit.position.snapped(Vector2(.1, .1))), unit.progress, unit.reached_base, (unit.target.def.get("id", "") if unit.target != null else "-"), unit.hp, unit.max_hp])
var h: Dictionary = match_state.history[-1]
print("R%02d %s %.1fs buildings %s lost %s gold %s" % [h.round, h.end_reason, h.combat_time, str(h.buildings), str(h.lost), str(h.gold)])
var result: String = "Testlimit erreicht / Partie nicht abgeschlossen"
if match_state.phase == LuneMatch.Phase.OVER:
result = "Unentschieden" if match_state.winner == LuneMatch.DRAW else ("Sieg Blau" if match_state.winner == 0 else "Sieg Rot")
var report := {"seed": seed, "result": result, "rounds": match_state.history.size(), "note": match_state.end_note,
"profiles": range(6).map(func(s: int) -> String: return LuneBot.profile_for(match_state, s)),
"bunds": match_state.players.map(func(p: Dictionary) -> String: return p.bund),
"wall_ms": Time.get_ticks_msec() - started, "history": match_state.history}
reports.append(report)
print("SIM_MATCH seed=%d %s after %d rounds (%s) in %d ms" % [seed, result, report.rounds, report.note, report.wall_ms])
if not out.is_empty():
var file := FileAccess.open(out, FileAccess.WRITE)
file.store_string(JSON.stringify(reports, "\t"))
file.close()
get_tree().quit()

View file

@ -0,0 +1 @@
uid://2s0skfdjcwno

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/luneraria/sim_match.gd" id="1"]
[node name="SimMatch" type="Node"]
script = ExtResource("1")