diff --git a/data/luneraria/balance_default.tres b/data/luneraria/balance_default.tres new file mode 100644 index 0000000..a250626 --- /dev/null +++ b/data/luneraria/balance_default.tres @@ -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") diff --git a/scripts/luneraria/core/lune_balance.gd b/scripts/luneraria/core/lune_balance.gd new file mode 100644 index 0000000..99ce47f --- /dev/null +++ b/scripts/luneraria/core/lune_balance.gd @@ -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)) diff --git a/scripts/luneraria/core/lune_balance.gd.uid b/scripts/luneraria/core/lune_balance.gd.uid new file mode 100644 index 0000000..8cee361 --- /dev/null +++ b/scripts/luneraria/core/lune_balance.gd.uid @@ -0,0 +1 @@ +uid://daf3k13ys862 diff --git a/scripts/luneraria/core/lune_bot.gd b/scripts/luneraria/core/lune_bot.gd new file mode 100644 index 0000000..ba39285 --- /dev/null +++ b/scripts/luneraria/core/lune_bot.gd @@ -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 diff --git a/scripts/luneraria/core/lune_bot.gd.uid b/scripts/luneraria/core/lune_bot.gd.uid new file mode 100644 index 0000000..d8a031b --- /dev/null +++ b/scripts/luneraria/core/lune_bot.gd.uid @@ -0,0 +1 @@ +uid://dy31vyfmvjk0s diff --git a/scripts/luneraria/core/lune_combat.gd b/scripts/luneraria/core/lune_combat.gd new file mode 100644 index 0000000..ed86487 --- /dev/null +++ b/scripts/luneraria/core/lune_combat.gd @@ -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 diff --git a/scripts/luneraria/core/lune_combat.gd.uid b/scripts/luneraria/core/lune_combat.gd.uid new file mode 100644 index 0000000..76ebd13 --- /dev/null +++ b/scripts/luneraria/core/lune_combat.gd.uid @@ -0,0 +1 @@ +uid://rj0ekj8y2rsa diff --git a/scripts/luneraria/core/lune_map.gd b/scripts/luneraria/core/lune_map.gd new file mode 100644 index 0000000..2225624 --- /dev/null +++ b/scripts/luneraria/core/lune_map.gd @@ -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 diff --git a/scripts/luneraria/core/lune_map.gd.uid b/scripts/luneraria/core/lune_map.gd.uid new file mode 100644 index 0000000..6e95436 --- /dev/null +++ b/scripts/luneraria/core/lune_map.gd.uid @@ -0,0 +1 @@ +uid://bqnx0kaccktd5 diff --git a/scripts/luneraria/core/lune_match.gd b/scripts/luneraria/core/lune_match.gd new file mode 100644 index 0000000..1fdbb6d --- /dev/null +++ b/scripts/luneraria/core/lune_match.gd @@ -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) diff --git a/scripts/luneraria/core/lune_match.gd.uid b/scripts/luneraria/core/lune_match.gd.uid new file mode 100644 index 0000000..4af623a --- /dev/null +++ b/scripts/luneraria/core/lune_match.gd.uid @@ -0,0 +1 @@ +uid://dgn4ftbspq233 diff --git a/scripts/luneraria/core/lune_unit.gd b/scripts/luneraria/core/lune_unit.gd new file mode 100644 index 0000000..f792ebb --- /dev/null +++ b/scripts/luneraria/core/lune_unit.gd @@ -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 diff --git a/scripts/luneraria/core/lune_unit.gd.uid b/scripts/luneraria/core/lune_unit.gd.uid new file mode 100644 index 0000000..5df821f --- /dev/null +++ b/scripts/luneraria/core/lune_unit.gd.uid @@ -0,0 +1 @@ +uid://3658nsy6hfo diff --git a/scripts/luneraria/core/lune_waves.gd b/scripts/luneraria/core/lune_waves.gd new file mode 100644 index 0000000..738dab7 --- /dev/null +++ b/scripts/luneraria/core/lune_waves.gd @@ -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 diff --git a/scripts/luneraria/core/lune_waves.gd.uid b/scripts/luneraria/core/lune_waves.gd.uid new file mode 100644 index 0000000..e12aaca --- /dev/null +++ b/scripts/luneraria/core/lune_waves.gd.uid @@ -0,0 +1 @@ +uid://c1fsheercsrib diff --git a/tests/luneraria/test_lune_core.gd b/tests/luneraria/test_lune_core.gd new file mode 100644 index 0000000..bb1b92e --- /dev/null +++ b/tests/luneraria/test_lune_core.gd @@ -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()]) diff --git a/tests/luneraria/test_lune_core.tscn b/tests/luneraria/test_lune_core.tscn new file mode 100644 index 0000000..3533a2c --- /dev/null +++ b/tests/luneraria/test_lune_core.tscn @@ -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") diff --git a/tools/luneraria/sim_match.gd b/tools/luneraria/sim_match.gd new file mode 100644 index 0000000..f1655e8 --- /dev/null +++ b/tools/luneraria/sim_match.gd @@ -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() diff --git a/tools/luneraria/sim_match.gd.uid b/tools/luneraria/sim_match.gd.uid new file mode 100644 index 0000000..09bdccb --- /dev/null +++ b/tools/luneraria/sim_match.gd.uid @@ -0,0 +1 @@ +uid://2s0skfdjcwno diff --git a/tools/luneraria/sim_match.tscn b/tools/luneraria/sim_match.tscn new file mode 100644 index 0000000..daefdc9 --- /dev/null +++ b/tools/luneraria/sim_match.tscn @@ -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")