class_name LuneLaneThreat extends RefCounted ## Deterministic lane threat for one team, read only from public state: building HP, the ## units on the battlefield (positions and HP, which every client also sees) and the public ## announcements of this round. It never reads drafts, gold, unannounced bookings or bot ## notes, so host and guest compute the same level. Positions are projected onto the lane, ## because a network guest has positions but no lane progress. ## ## RUHIG nothing below applies ## UMKÄMPFT a lane tower is below 85 %, an enemy attack champion is announced here, or ## enemy units stand in the own half of the lane ## UNTER DRUCK the Grenzwacht fell or is below 50 %, enemies attack the Grenzwacht, the enemy ## has more than 1.5 times the own unit HP in the own half, or the enemy boss ## is deployed on this lane ## KRITISCH both lane towers fell (base open), the Bastion is below 50 % after the ## Grenzwacht fell, enemies attack the Bastion, or the enemy boss comes while the ## Grenzwacht is already down enum Level { CALM, CONTESTED, PRESSURE, CRITICAL } const NAMES: Array[String] = ["RUHIG", "UMKÄMPFT", "UNTER DRUCK", "KRITISCH"] const GLYPHS: Array[String] = ["check", "melee", "warning", "warning"] static func _building(state: LuneMatch, id: String) -> Array: var max_hp: float = float(state.map.building_def(id).get("max_hp", 1.0)) if state.phase == LuneMatch.Phase.COMBAT and state.combat != null and state.combat.buildings.has(id): var unit: LuneUnit = state.combat.buildings[id] return [unit.alive, clampf(unit.hp / maxf(1.0, max_hp), 0.0, 1.0) if unit.alive else 0.0] var hp: float = float(state.building_hp.get(id, 0.0)) return [hp > 0.0, clampf(hp / maxf(1.0, max_hp), 0.0, 1.0)] static func _percent(value: float) -> String: return "%d %%" % int(round(value * 100.0)) static func evaluate(state: LuneMatch, team: int, lane: String) -> Dictionary: var reasons: PackedStringArray = [] var level: int = Level.CALM var prefix: String = "blau" if team == 0 else "rot" var enemy: int = 1 - team var outer: Array = _building(state, "%s_%s_outer" % [prefix, lane]) var inner: Array = _building(state, "%s_%s_inner" % [prefix, lane]) if not outer[0] and not inner[0]: level = Level.CRITICAL reasons.append("Grenzwacht und Bastion gefallen – der Weg zur Basis ist offen") elif not outer[0]: level = Level.PRESSURE reasons.append("Grenzwacht gefallen") if float(inner[1]) < .5: level = Level.CRITICAL reasons.append("Bastion bei " + _percent(inner[1])) elif float(inner[1]) < .85: reasons.append("Bastion bei " + _percent(inner[1])) elif float(outer[1]) < .5: level = Level.PRESSURE reasons.append("Grenzwacht bei " + _percent(outer[1])) elif float(outer[1]) < .85: level = Level.CONTESTED reasons.append("Grenzwacht bei " + _percent(outer[1])) for entry: Dictionary in state.announcements(): if int(entry.team) != enemy or str(entry.lane) != lane: continue if entry.kind == "boss": level = maxi(level, Level.CRITICAL if not outer[0] else Level.PRESSURE) reasons.append("Gegnerische Schlundkrone greift hier an") else: level = maxi(level, Level.CONTESTED) reasons.append("Angriffschampion %s angekündigt" % LuneContent.CHAMPIONS[entry.champion_id].name) if state.phase == LuneMatch.Phase.COMBAT and state.combat != null: var battle: Dictionary = battlefield(state, team, lane) var length: float = float(battle.length) if int(battle.enemy_count) > 0: var deepest: float = float(battle.enemy_depth) var inner_at: float = length * state.balance.inner_tower_fraction var outer_at: float = length * state.balance.outer_tower_fraction var reach: float = state.balance.tower_range * .8 if deepest <= inner_at + reach: level = Level.CRITICAL reasons.append("Gegner greifen die Bastion oder die Basis an") elif deepest <= outer_at + reach and outer[0]: level = maxi(level, Level.PRESSURE) reasons.append("Gegner greifen die Grenzwacht an") elif deepest <= length * .5: level = maxi(level, Level.CONTESTED) reasons.append("Gegner in der eigenen Lanehälfte") if float(battle.enemy_hp) > float(battle.own_hp) * 1.5 and deepest <= length * .5: level = maxi(level, Level.PRESSURE) reasons.append("Übermacht: %d gegen %d Einheiten" % [int(battle.enemy_count), int(battle.own_count)]) return {"level": level, "name": NAMES[level], "reasons": reasons} ## Units of both sides on one lane: counts, HP sums and the deepest enemy position measured ## from the own base along the lane (from positions only). static func battlefield(state: LuneMatch, team: int, lane: String) -> Dictionary: var path: LuneMap.LanePath = state.map.lanes[lane] var result: Dictionary = {"length": path.length, "own_count": 0, "enemy_count": 0, "own_hp": 0.0, "enemy_hp": 0.0, "enemy_depth": INF, "own_front": 0.0} if state.combat == null: return result for unit: LuneUnit in state.combat.units: if not unit.alive or not unit.is_mobile() or unit.zone != "lane" or unit.lane != lane or unit.team > 1: continue var s: float = path.project(unit.position) var from_own_base: float = s if team == 0 else path.length - s if unit.team == team: result.own_count = int(result.own_count) + 1 result.own_hp = float(result.own_hp) + unit.hp result.own_front = maxf(float(result.own_front), from_own_base) else: result.enemy_count = int(result.enemy_count) + 1 result.enemy_hp = float(result.enemy_hp) + unit.hp result.enemy_depth = minf(float(result.enemy_depth), from_own_base) return result