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:
parent
4b6a4f530e
commit
b565546c37
20 changed files with 1636 additions and 0 deletions
6
data/luneraria/balance_default.tres
Normal file
6
data/luneraria/balance_default.tres
Normal 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")
|
||||||
69
scripts/luneraria/core/lune_balance.gd
Normal file
69
scripts/luneraria/core/lune_balance.gd
Normal 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))
|
||||||
1
scripts/luneraria/core/lune_balance.gd.uid
Normal file
1
scripts/luneraria/core/lune_balance.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://daf3k13ys862
|
||||||
81
scripts/luneraria/core/lune_bot.gd
Normal file
81
scripts/luneraria/core/lune_bot.gd
Normal 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
|
||||||
1
scripts/luneraria/core/lune_bot.gd.uid
Normal file
1
scripts/luneraria/core/lune_bot.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dy31vyfmvjk0s
|
||||||
436
scripts/luneraria/core/lune_combat.gd
Normal file
436
scripts/luneraria/core/lune_combat.gd
Normal 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
|
||||||
1
scripts/luneraria/core/lune_combat.gd.uid
Normal file
1
scripts/luneraria/core/lune_combat.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://rj0ekj8y2rsa
|
||||||
155
scripts/luneraria/core/lune_map.gd
Normal file
155
scripts/luneraria/core/lune_map.gd
Normal 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
|
||||||
1
scripts/luneraria/core/lune_map.gd.uid
Normal file
1
scripts/luneraria/core/lune_map.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bqnx0kaccktd5
|
||||||
325
scripts/luneraria/core/lune_match.gd
Normal file
325
scripts/luneraria/core/lune_match.gd
Normal 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)
|
||||||
1
scripts/luneraria/core/lune_match.gd.uid
Normal file
1
scripts/luneraria/core/lune_match.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dgn4ftbspq233
|
||||||
161
scripts/luneraria/core/lune_unit.gd
Normal file
161
scripts/luneraria/core/lune_unit.gd
Normal 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
|
||||||
1
scripts/luneraria/core/lune_unit.gd.uid
Normal file
1
scripts/luneraria/core/lune_unit.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://3658nsy6hfo
|
||||||
38
scripts/luneraria/core/lune_waves.gd
Normal file
38
scripts/luneraria/core/lune_waves.gd
Normal 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
|
||||||
1
scripts/luneraria/core/lune_waves.gd.uid
Normal file
1
scripts/luneraria/core/lune_waves.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c1fsheercsrib
|
||||||
297
tests/luneraria/test_lune_core.gd
Normal file
297
tests/luneraria/test_lune_core.gd
Normal 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()])
|
||||||
6
tests/luneraria/test_lune_core.tscn
Normal file
6
tests/luneraria/test_lune_core.tscn
Normal 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")
|
||||||
48
tools/luneraria/sim_match.gd
Normal file
48
tools/luneraria/sim_match.gd
Normal 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()
|
||||||
1
tools/luneraria/sim_match.gd.uid
Normal file
1
tools/luneraria/sim_match.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://2s0skfdjcwno
|
||||||
6
tools/luneraria/sim_match.tscn
Normal file
6
tools/luneraria/sim_match.tscn
Normal 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")
|
||||||
Loading…
Reference in a new issue