Files

670 lines
22 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name AttackDirector
extends Node
## Атаки часа: мусор, DDoS, фишинг, шифровальщик.
##
## Тик идёт из HourDirector (единый Clock). Атаки не занимают буфер (D07),
## спавнятся в окне 60–240 с часа и не открываются во время мини-игры.
## Расписание: min(4, 1 + floor((hour−2)/2)); час 1 — без атак
## (junk открывается с часа 2, D23).
signal attack_added(id: String)
signal attack_changed(id: String)
signal attack_removed(id: String)
signal toast(text: String)
signal resolved(id: String, success: bool)
const KINDS: Array[String] = ["junk", "ddos", "phishing", "ransomware"]
const KIND_WEIGHTS: Array[float] = [3.0, 2.0, 2.0, 2.0]
const WINDOW_MIN := 60.0
const WINDOW_MAX := 240.0
const DDOS_RESPONSE_SEC := 8.0
const FALLBACK = {
"junk": {
"name_ru": "Мусорный пакет", "duration_sec": 15.0,
"reward_money": 5, "reward_sla": 1, "fail_money": -10, "fail_sla": -5,
"filter_disabled_sec": 10.0,
},
"ddos": {
"name_ru": "DDoS", "cards_min": 8, "cards_max": 12,
"spawn_window_sec": 2.0, "shield_hold_sec": 1.5,
"reward_money": 30, "reward_sla": 2, "fail_sla": -15,
"channel_debuff": 0.4, "channel_debuff_sec": 15.0,
"packets_lost": 2, "recv_disabled_sec": 5.0,
},
"phishing": {
"name_ru": "Фишинг",
"reward_money": 20, "reward_sla": 2, "fail_money": -40, "fail_sla": -10,
"file_lock_sec": 60.0, "send_disabled_sec": 5.0,
"timeout_money": -20, "timeout_sla": -5, "timeout_lock_sec": 30.0,
},
"ransomware": {
"name_ru": "Шифровальщик", "folder_block_delay_sec": 3.0,
"auto_unlock_sec": 30.0, "auto_unlock_money": -30, "auto_unlock_sla": -10,
"reward_money": 25, "reward_sla": 2,
},
}
## Ссылка на HourDirector (без type hint — избегаем циклических preload).
var director = null
var active_attacks: Dictionary = {}
var hour: int = 1
var elapsed: float = 0.0
var _schedule: Array[float] = []
var _next_index: int = 0
var _counter: int = 0
var _allow_spawn: bool = true
var _ransomware_files: Dictionary = {}
## Апгрейд «Щит»: сколько раз автоотражение уже сработало в этом часе.
var shield_used_this_hour: int = 0
func _init() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
func setup(director_node) -> void:
director = director_node
func reset(p_hour: int) -> void:
# Старые атаки часа не переносятся.
for id in active_attacks.keys():
active_attacks.erase(id)
attack_removed.emit(str(id))
_ransomware_files.clear()
hour = maxi(1, int(p_hour))
elapsed = 0.0
_counter = 0
_next_index = 0
_schedule.clear()
var count := attacks_for_hour(hour)
for i in range(count):
_schedule.append(_randf_range(WINDOW_MIN, WINDOW_MAX))
_schedule.sort()
_allow_spawn = true
shield_used_this_hour = 0
func set_allow_spawn(value: bool) -> void:
_allow_spawn = value
## Сколько атак запланировано на час (D23: атаки с часа 2, час 1 — 0).
static func attacks_for_hour(p_hour: int) -> int:
var h := maxi(1, int(p_hour))
if h == 1:
return 0
return clampi(1 + int(floor(float(h - 2) / 2.0)), 1, 4)
func tick(delta: float) -> void:
# HourDirector уже применил D11-кап и timescale, поэтому здесь не режем
# delta повторно (иначе ускоренные тесты не доживают до окна атак).
var dt := maxf(delta, 0.0)
elapsed += dt
_tick_active(dt)
_tick_ransomware(dt)
_maybe_spawn()
## Публичный спавн (UI/тесты). kind="" — случайный по весам среди открытых.
## Закрытая в прогрессии атака не спавнится (п.16).
func spawn_attack(kind: String = "") -> String:
var chosen := kind
if chosen.is_empty():
chosen = _pick_kind()
if chosen.is_empty():
return ""
if not KINDS.has(chosen):
chosen = "junk"
if not _kind_unlocked(chosen):
return ""
var id := "atk_%d_%d" % [hour, _counter]
_counter += 1
# Апгрейд «Щит» (shop_shield): первый DDoS часа отражается автоматически —
# награда как за ручное отражение, без урона по каналу и SLA.
if chosen == "ddos" and shield_unlocked() and shield_used_this_hour < 1:
shield_used_this_hour += 1
var ddos_cfg := _cfg("ddos")
_add_money(int(ddos_cfg.get("reward_money", 30)))
_add_sla(float(ddos_cfg.get("reward_sla", 2)))
toast.emit("ЩИТ ОТРАЗИЛ DDoS")
_play_sfx(&"attack_blocked")
# Атака не попадает в active_attacks, но учитывается как отражённая.
resolved.emit(id, true)
return id
var cfg := _cfg(chosen)
var data := {
"id": id,
"kind": chosen,
"name": str(cfg.get("name_ru", chosen)),
"time_left": 0.0,
"total": 0.0,
"cards": 1,
"shield_hold": 0.0,
"shield_needed": 0.0,
"request_id": "",
"packet_id": "",
"file_id": "",
}
match chosen:
"junk":
var duration := float(cfg.get("duration_sec", 15.0))
data["time_left"] = duration
data["total"] = duration
var junk_packet := ""
if director != null and director.has_method("spawn_junk_packet"):
junk_packet = str(director.spawn_junk_packet(id))
if junk_packet.is_empty():
return ""
data["packet_id"] = junk_packet
"ddos":
var cards := _randi_range(int(cfg.get("cards_min", 8)), int(cfg.get("cards_max", 12)))
var window := float(cfg.get("spawn_window_sec", 2.0)) + DDOS_RESPONSE_SEC
data["cards"] = cards
data["time_left"] = window
data["total"] = window
data["shield_needed"] = float(cfg.get("shield_hold_sec", 1.5))
"phishing":
var request_id := ""
if director != null and director.has_method("spawn_phishing_request"):
request_id = str(director.spawn_phishing_request(id))
if request_id.is_empty():
return ""
data["request_id"] = request_id
data["time_left"] = 30.0
data["total"] = 30.0
"ransomware":
var packet_id := ""
if director != null and director.has_method("spawn_ransomware_packet"):
packet_id = str(director.spawn_ransomware_packet(id))
if packet_id.is_empty():
return ""
data["packet_id"] = packet_id
data["time_left"] = 20.0
data["total"] = 20.0
active_attacks[id] = data
attack_added.emit(id)
toast.emit("АТАКА: %s" % str(data.get("name", chosen)))
_play_sfx(&"attack")
return id
## Уничтожить карточку атаки (мусор/шифровальщик). Возвращает true при успехе.
func destroy(id: String) -> bool:
if not active_attacks.has(id):
return false
var data: Dictionary = active_attacks[id]
var kind := str(data.get("kind", ""))
match kind:
"junk":
var packet_id := str(data.get("packet_id", ""))
if not packet_id.is_empty() and director != null and director.has_method("destroy_attack_packet"):
return bool(director.destroy_attack_packet(packet_id))
var cfg := _cfg("junk")
_add_money(int(cfg.get("reward_money", 5)))
_add_sla(float(cfg.get("reward_sla", 1)))
_remove(id, true)
return true
"ransomware":
var packet_id := str(data.get("packet_id", ""))
if not packet_id.is_empty() and director != null and director.has_method("destroy_attack_packet"):
return bool(director.destroy_attack_packet(packet_id))
return false
return false
## Drag в карантин: для мусора эквивалентно уничтожению.
func quarantine(id: String) -> bool:
return destroy(id)
## Пакет-мусор уничтожен/отправлен в карантин: награда, карточка убирается.
func on_junk_packet_destroyed(packet_id: String) -> bool:
for id in active_attacks.keys():
var data: Dictionary = active_attacks[id]
if str(data.get("kind", "")) == "junk" and str(data.get("packet_id", "")) == packet_id:
var cfg := _cfg("junk")
_add_money(int(cfg.get("reward_money", 5)))
_add_sla(float(cfg.get("reward_sla", 1)))
_remove(str(id), true)
return true
return false
## Удержание щита против DDoS: накапливает секунды, при 1.5 с — отражение.
func hold_shield(seconds: float) -> bool:
var held := false
for id in active_attacks.keys():
var data: Dictionary = active_attacks[id]
if str(data.get("kind", "")) != "ddos":
continue
held = true
data["shield_hold"] = float(data.get("shield_hold", 0.0)) + maxf(0.0, seconds)
if float(data.get("shield_hold", 0.0)) >= float(data.get("shield_needed", 1.5)):
_resolve_ddos(str(id), true)
else:
attack_changed.emit(str(id))
if held:
_play_sfx(&"attack_blocked")
return held
## Завершение фишинга: "rejected", "timeout", "sent" или "destroyed".
func resolve_phishing(request_id: String, outcome: String) -> void:
var attack_id := ""
for id in active_attacks.keys():
if str(active_attacks[id].get("request_id", "")) == request_id:
attack_id = str(id)
break
if attack_id.is_empty():
return
var cfg := _cfg("phishing")
match outcome:
"rejected", "destroyed":
_add_money(int(cfg.get("reward_money", 20)))
_add_sla(float(cfg.get("reward_sla", 2)))
_remove(attack_id, true)
"timeout":
_add_money(int(cfg.get("timeout_money", -20)))
_add_sla(float(cfg.get("timeout_sla", -5)))
if director != null and director.has_method("attack_lock_random_file"):
director.attack_lock_random_file(float(cfg.get("timeout_lock_sec", 30.0)))
_remove(attack_id, false)
_:
_add_money(int(cfg.get("fail_money", -40)))
_add_sla(float(cfg.get("fail_sla", -10)))
if director != null:
if director.has_method("attack_disable_send"):
director.attack_disable_send(float(cfg.get("send_disabled_sec", 5.0)))
if director.has_method("attack_lock_random_file"):
director.attack_lock_random_file(float(cfg.get("file_lock_sec", 60.0)))
_remove(attack_id, false)
## Шифровальщик попал на склад: файл помечен malicious и ждёт 30 с.
func on_ransomware_file(file_id: String, attack_id: String) -> void:
if file_id.is_empty():
return
var game = _game()
var folder_id := ""
if game != null and game.registry != null:
game.registry.mark_malicious(file_id, "ransomware")
var registry_file = game.registry.files.get(file_id)
if registry_file != null:
folder_id = str(registry_file.folder_id)
var cfg := _cfg("ransomware")
_ransomware_files[file_id] = {
"attack_id": attack_id,
"folder_id": folder_id,
"left": float(cfg.get("auto_unlock_sec", 30.0)),
"block_left": float(cfg.get("folder_block_delay_sec", 3.0)),
"blocked": false,
}
if active_attacks.has(attack_id):
active_attacks[attack_id]["file_id"] = file_id
active_attacks[attack_id]["packet_id"] = ""
attack_changed.emit(attack_id)
toast.emit("ШИФРОВАЛЬЩИК: файл заблокирован, уничтожьте его")
## Файл-шифровальщик уничтожен игроком — награда.
func on_file_destroyed(file_id: String) -> bool:
if not _ransomware_files.has(file_id):
return false
var info: Dictionary = _ransomware_files[file_id]
var attack_id := str(info.get("attack_id", ""))
_clear_folder_block(info)
var cfg := _cfg("ransomware")
_add_money(int(cfg.get("reward_money", 25)))
_add_sla(float(cfg.get("reward_sla", 2)))
_ransomware_files.erase(file_id)
if active_attacks.has(attack_id):
_remove(attack_id, true)
else:
toast.emit("Шифровальщик уничтожен")
return true
## Пакет-шифровальщик уничтожен до заноса.
func on_ransomware_packet_destroyed(packet_id: String) -> bool:
for id in active_attacks.keys():
var data: Dictionary = active_attacks[id]
if str(data.get("kind", "")) == "ransomware" and str(data.get("packet_id", "")) == packet_id:
var cfg := _cfg("ransomware")
_add_money(int(cfg.get("reward_money", 25)))
_add_sla(float(cfg.get("reward_sla", 2)))
_remove(str(id), true)
return true
return false
func kind_name(kind: String) -> String:
return str(_cfg(kind).get("name_ru", kind))
## Куплен ли апгрейд «Щит» (shop_shield → Game.upgrades.shield_unlocked).
func shield_unlocked() -> bool:
var game = _game()
if game == null:
return false
var upgrades = game.get("upgrades")
if not (upgrades is Dictionary):
return false
return int((upgrades as Dictionary).get("shield_unlocked", 0)) > 0
# --- тик -------------------------------------------------------------------
func _tick_active(dt: float) -> void:
for id in active_attacks.keys():
if not active_attacks.has(id):
continue
var data: Dictionary = active_attacks[id]
var kind := str(data.get("kind", ""))
if kind == "phishing":
# Таймер запроса ведёт HourDirector; здесь только синхронизация карточки.
if data.get("request_id", "") != "" and director != null and not director.has_request(str(data.get("request_id", ""))):
_remove(str(id), false)
continue
if kind == "ransomware":
var packet_id := str(data.get("packet_id", ""))
if packet_id.is_empty():
continue
if director != null and not director.has_packet(packet_id):
# Пакет уже занесён/уничтожен — карточку атаки убираем.
_remove(str(id), false)
continue
if kind == "junk":
var junk_packet := str(data.get("packet_id", ""))
if junk_packet.is_empty() or (director != null and not director.has_packet(junk_packet)):
# Пакет вытеснен из буфера/уже обработан — карточка неактуальна.
_remove(str(id), false)
continue
data["time_left"] = float(data.get("time_left", 0.0)) - dt
if kind == "junk" and float(data["time_left"]) <= 0.0:
_fail_junk(str(id))
elif kind == "ddos":
if float(data["time_left"]) <= 0.0:
_resolve_ddos(str(id), false)
else:
attack_changed.emit(str(id))
func _tick_ransomware(dt: float) -> void:
var game = _game()
for file_id in _ransomware_files.keys():
if not _ransomware_files.has(file_id):
continue
var info: Dictionary = _ransomware_files[file_id]
var attack_id := str(info.get("attack_id", ""))
# Файл уже уничтожен через FileRegistry — засчитываем отражение.
if game == null or game.registry == null or not game.registry.files.has(file_id):
_reward_ransomware(str(file_id), attack_id)
continue
# folder_block_delay_sec: папка и файл блокируются с задержкой.
if not bool(info.get("blocked", false)):
info["block_left"] = float(info.get("block_left", 0.0)) - dt
if float(info["block_left"]) <= 0.0:
var folder_id := str(info.get("folder_id", ""))
var left: float = maxf(0.1, float(info.get("left", 30.0)))
if director != null and director.has_method("lock_ransomware_folder"):
director.lock_ransomware_folder(folder_id, str(file_id), left)
info["blocked"] = true
info["left"] = float(info.get("left", 0.0)) - dt
if float(info["left"]) > 0.0:
continue
var cfg := _cfg("ransomware")
_add_money(int(cfg.get("auto_unlock_money", -30)))
_add_sla(float(cfg.get("auto_unlock_sla", -10)))
_remove_ransomware_file(str(file_id))
_clear_folder_block(info)
_ransomware_files.erase(file_id)
if active_attacks.has(attack_id):
_remove(attack_id, false)
toast.emit("Шифровальщик: файл потерян, −30 $, −10 SLA")
## Авто-unlock: снять lock и убрать файл (lock не даёт remove_file напрямую).
func _remove_ransomware_file(file_id: String) -> void:
var game = _game()
if game == null or game.registry == null or not game.registry.files.has(file_id):
return
var file = game.registry.files[file_id]
if str(file.location) == "locked":
game.registry.unlock_file(file_id)
file = game.registry.files.get(file_id)
if file == null:
return
if str(file.location) != "malicious":
game.registry.mark_malicious(file_id, "ransomware")
game.registry.remove_file(file_id)
func _clear_folder_block(info: Dictionary) -> void:
var folder_id := str(info.get("folder_id", ""))
if folder_id.is_empty() or director == null:
return
if director.has_method("unlock_ransomware_folder"):
director.unlock_ransomware_folder(folder_id)
func _reward_ransomware(file_id: String, attack_id: String) -> void:
var cfg := _cfg("ransomware")
if _ransomware_files.has(file_id):
_clear_folder_block(_ransomware_files[file_id])
_add_money(int(cfg.get("reward_money", 25)))
_add_sla(float(cfg.get("reward_sla", 2)))
_ransomware_files.erase(file_id)
if active_attacks.has(attack_id):
_remove(attack_id, true)
else:
toast.emit("Шифровальщик уничтожен")
func _maybe_spawn() -> void:
if not _allow_spawn:
return
if _next_index >= _schedule.size():
return
if elapsed > WINDOW_MAX:
_next_index = _schedule.size()
return
if elapsed < float(_schedule[_next_index]):
return
var kind := "junk" if hour <= 1 else _pick_kind()
if spawn_attack(kind).is_empty():
# Не удалось (например, нет файлов для фишинга) — пропускаем слот.
pass
_next_index += 1
func _fail_junk(id: String) -> void:
var cfg := _cfg("junk")
var data: Dictionary = active_attacks.get(id, {})
var packet_id := str(data.get("packet_id", ""))
if not packet_id.is_empty() and director != null and director.has_method("junk_auto_carry"):
director.junk_auto_carry(packet_id, id)
_add_money(int(cfg.get("fail_money", -10)))
_add_sla(float(cfg.get("fail_sla", -5)))
if director != null:
if director.has_method("attack_disable_recv"):
director.attack_disable_recv(float(cfg.get("filter_disabled_sec", 10.0)))
if director.has_method("attack_disable_send"):
director.attack_disable_send(5.0)
_remove(id, false)
toast.emit("Мусор попал в приём: −10 $, −5 SLA")
func _resolve_ddos(id: String, success: bool) -> void:
if not active_attacks.has(id):
return
var cfg := _cfg("ddos")
if success:
_add_money(int(cfg.get("reward_money", 30)))
_add_sla(float(cfg.get("reward_sla", 2)))
else:
_add_sla(float(cfg.get("fail_sla", -15)))
if director != null:
if director.has_method("attack_channel_debuff"):
director.attack_channel_debuff(float(cfg.get("channel_debuff_sec", 15.0)))
if director.has_method("attack_lose_packets"):
director.attack_lose_packets(int(cfg.get("packets_lost", 2)))
if director.has_method("attack_disable_recv"):
director.attack_disable_recv(float(cfg.get("recv_disabled_sec", 5.0)))
toast.emit("DDoS пробил канал: −15 SLA, канал замедлен")
if success:
toast.emit("DDoS отражён: +30 $, +2 SLA")
_remove(id, success)
func _remove(id: String, success: bool) -> void:
if not active_attacks.has(id):
return
active_attacks.erase(id)
attack_removed.emit(id)
resolved.emit(id, success)
# --- служебное -------------------------------------------------------------
## Открыта ли атака: progression.unlocks → attack_<kind>.
func _kind_unlocked(kind: String) -> bool:
return _feature_unlocked("attack_" + str(kind))
func _feature_unlocked(id: String) -> bool:
var game = _game()
if game != null and game.has_method("feature_unlocked"):
return bool(game.feature_unlocked(id))
return true
func _unlocked_kinds() -> Array[String]:
var out: Array[String] = []
for kind in KINDS:
if _kind_unlocked(kind):
out.append(kind)
return out
func _pick_kind() -> String:
var kinds := _unlocked_kinds()
if kinds.is_empty():
return ""
if hour <= 1:
return "junk" if kinds.has("junk") else ""
var weights: Array[float] = []
for kind in kinds:
weights.append(KIND_WEIGHTS[KINDS.find(kind)])
var index := _weighted_index(weights)
if index < 0 or index >= kinds.size():
return kinds[0]
return kinds[index]
func _weighted_index(weights: Array) -> int:
var total := 0.0
for w in weights:
total += maxf(0.0, float(w))
if total <= 0.0:
return 0
var roll := _randf() * total
var acc := 0.0
for i in range(weights.size()):
acc += maxf(0.0, float(weights[i]))
if roll < acc:
return i
return weights.size() - 1
func _cfg(kind: String) -> Dictionary:
var data = _data()
if data != null and data.get("balance") is Dictionary:
var attacks = data.balance.get("attacks", null)
if attacks is Dictionary and attacks.get(kind, null) is Dictionary:
return attacks[kind]
return FALLBACK.get(kind, FALLBACK["junk"])
func _add_money(delta: int) -> void:
var game = _game()
if game == null:
return
if game.has_method("add_money"):
game.add_money(delta, "attack")
else:
game.money = maxi(0, int(game.money) + delta)
func _add_sla(delta: float) -> void:
var game = _game()
if game == null:
return
if game.has_method("add_sla"):
game.add_sla(delta, "attack")
func _randf() -> float:
if director != null and director.get("rng") != null:
var rng = director.rng
if rng.has_method("randf"):
return float(rng.randf())
var loop := Engine.get_main_loop()
if loop is SceneTree:
var rng = (loop as SceneTree).root.get_node_or_null("Rng")
if rng != null and rng.has_method("randf"):
return float(rng.randf())
return randf()
func _randf_range(from: float, to: float) -> float:
if director != null and director.get("rng") != null:
var rng = director.rng
if rng.has_method("randf_range"):
return float(rng.randf_range(from, to))
var loop := Engine.get_main_loop()
if loop is SceneTree:
var rng = (loop as SceneTree).root.get_node_or_null("Rng")
if rng != null and rng.has_method("randf_range"):
return float(rng.randf_range(from, to))
return randf_range(from, to)
func _randi_range(from: int, to: int) -> int:
if director != null and director.get("rng") != null:
var rng = director.rng
if rng.has_method("randi_range"):
return int(rng.randi_range(from, to))
var loop := Engine.get_main_loop()
if loop is SceneTree:
var rng = (loop as SceneTree).root.get_node_or_null("Rng")
if rng != null and rng.has_method("randi_range"):
return int(rng.randi_range(from, to))
return randi_range(from, to)
func _game():
var loop := Engine.get_main_loop()
if loop is SceneTree:
return (loop as SceneTree).root.get_node_or_null("Game")
return null
func _data():
var loop := Engine.get_main_loop()
if loop is SceneTree:
return (loop as SceneTree).root.get_node_or_null("Data")
return null
func _play_sfx(id: StringName) -> void:
var loop := Engine.get_main_loop()
if not (loop is SceneTree):
return
var audio = (loop as SceneTree).root.get_node_or_null("Audio")
if audio != null and audio.has_method("play_sfx"):
audio.play_sfx(str(id))