Статус: Реализовано (все 10 шагов §8 закрыты, 90 EditMode-тестов зелёные)


Детальная архитектура Фазы 2 из роадмапа (9. Roadmap реализации): единая модель эффектов + способности. Наполняет швы, заложенные пустыми в Фазе 1 (10. Архитектура реализации — Фаза 1). Дизайн-источник: 6. Боевая модель §5 (единая модель эффектов), 11. Стат-система §5 (потенция vs длительность). Следует пайплайну phase-design-pipeline: данные → швы → классы → спайк → тесты-имена.

Это план для ревью, а не финальный код. Сначала Макс читает и даёт мнение → потом реализуем.


0. Гейт: Фаза 1 закрыта (контекст этого дока)

Фаза 2 опирается на готовое ядро. Сверено по коду, не по статусам:

Что готово в Ф1Где
Тик-цикл, фикс. порядок систем, детерминизм + checksumCombatSimulation.Tick
Stats (слои модов по источнику, dirty-кэш, формула)Combat/Stats/Stats.cs
DamagePipeline (чистые функции, броня/пробивание/щит)Combat/Damage/DamagePipeline.cs
RuntimeUnit/Factory, spatial hash, авто-атаки, снарядыCombat/...
Швы под Ф2 (пустые): ICombatContext.ApplyEffect (no-op), EffectData/IEffectComponent/ScalableValue (скелеты), RelicData.GrantedEffects, enum StatType (все 30 объявлены), EffectPolarityData/..., Combat/ICombatContext.cs

Чего ещё НЕТ и что добавляет Ф2: RuntimeEffect, внутренняя event-queue (DrainEventQueue в тике отсутствует), EffectSystem, библиотека компонентов, способности (кулдаун/ресурс), диспел, проводка эфф-эффективностей в урон/хил.


1. Что Фаза 2 даёт и чего НЕ делает

Деливерабл: единая модель эффектов (баффы/дебаффы/DoT/HoT/контроль/щиты/триггеры/пассивки — один Effect с полиморфными компонентами), активные/пассивные способности с кулдауном и ресурсом, многоуровневый диспел, четыре эфф-эффективности длительности и потенция-множители исцеления/щитов — всё под headless EditMode-тестами.

✅ В Фазе 2⛔ НЕ в Фазе 2 (куда переезжает)
RuntimeEffect + жизненный цикл (apply/tick/expire)Решение «когда кастовать» (Filter/Score/Override) → Фаза 3. Ф2 даёт механизм каста + детерминированный плейсхолдер-триггер для тестов
Библиотека IRuntimeEffectComponent (StatMod, PeriodicDamage/Heal, Control, Shield, Lifesteal, Thorns, Dispel)Полный контент-набор эффектов/реликвий (~10 эфф., 6–8 реликвий) → Фаза 4 (Ф2 делает 2–3 представителя каждого типа для тестов)
ScalableValue в бою (потенция от статов источника)Odin-авторинг контента, метаданные статов → Фаза 4
Стакинг (StackRule/MaxStacks), EffectTagMaskГильдмастер-кастер GuildmasterController, гильд-широкие моды → каркас-шов в Ф2, наполнение → Фаза 5
Четыре эфф-эффективности длительности + HealShieldDealt/TakenEff (потенция) + CooldownEffСетевая репликация эффектов/ресурса → Фаза 6 (Ф2 держит детерминизм → реплицируется тем же путём команд)
AbilityRuntime: кулдаун, ресурс, каст активкиПрезентация эффектов (иконки баффов, тики DoT-цифр) → минимум в Ф2 (переиспользуем хуки Ф1), полировка → Фаза 7
Внутренняя event-queue (FIFO, без рекурсии) для реактивных компонентов
Многоуровневый диспел (CleanseTier vs DispelPower)

Принцип границы: Ф2 строит движок эффектов и способностей, а не контент. Реликвии/эффекты под срез наполняются в Ф4 через готовые точки; AI решает «когда» в Ф3.


2. Данные первыми (Уровень 1 — фиксируем)

Данные — хребет. Здесь — главные решения, дорогие задним числом.

2.1 EffectData (Data) — дополнить скелет до полной модели (§6 дизайна)

К текущим Id/Polarity/Tags/BaseDuration/Components добавляются поля стакинга и диспела:

ПолеТипНазначение
StackingStackRule (None/Stack/Refresh/StackAndRefresh)Поведение при повторном наложении
MaxStacksintПотолок стаков
CleanseTierintУстойчивость к диспелу (лестница vs DispelPower)
UnremovableboolНеснимаемо никаким диспелом
  • Tags — переводим со string[] на [Flags] EffectTag enum (Control/DoT/HoT/Heal/Shield/Buff/Debuff/…). Нужен для диспела по категории и EffectTagMask. Полный список тегов — открытый вопрос §9.
  • _baseDuration ≤ 0 семантика: 0 = мгновенный (один OnApply), -1 = постоянный (пассивка). Фиксируем сейчас.

2.2 RuntimeEffect (Combat, новый POCO) — экземпляр эффекта на юните

RuntimeEffect
├── Def              — ссылка на иммутабельный EffectData
├── Source           — кто наложил (атрибуция, скейл, триггеры)
├── RemainingTime    — в секундах сим (или тиках); 0/∞ по Def.BaseDuration
├── Stacks           — текущее число стаков
├── ratePerSecond[]  — ПОТЕНЦИЯ периодики, посчитана при наложении через ScalableValue
│                       (НЕ запечённый total — см. §11 §5.1: масштаб от длительности)
└── tickAccumulator  — для IPeriodicComponent.Interval

⚠️ Ключевое (док 11 §5.1): для DoT/HoT хранить rate за секунду, не готовый total. Длительность меняется эфф-эффектами → число тиков меняется → total пересчитывается сам. Запекать total при наложении НЕЛЬЗЯ.

2.3 RuntimeUnit (Combat) — новые поля (швы Ф1 ждут их)

ПолеТипКто мутирует
ActiveEffectsList<RuntimeEffect>EffectSystem (add → pending-очередь, не в коллекцию во время итерации)
EffectTagMaskEffectTag (битовая маска)EffectSystem при add/remove — быстрый запрос для AI (Ф3) и диспела
AbilitiesList<AbilityRuntime>RuntimeUnitFactory (сборка), AbilitySystem (тик)
Control-флагиCanAct/CanMove/CanCast (bool)ControlComponent через apply/expire; читают Movement/AutoAttack/Ability
  • CurrentResource/CurrentShield — поля уже есть с Ф1, в Ф2 их начинают наполнять (StartResource при сборке, ShieldComponent).

2.4 AbilityRuntime (Combat) + AbilityData (Data, на RelicData)

СущностьПоляПримечание
AbilityData (SO/вложенный)EffectData[] применяемых эффектов, baseCooldown, resourceCost, targetMode (Self/Enemy/Allies/Area), ScalableValue параметрыАктивки авторятся на реликвии; пассивки = GrantedEffects с Duration = -1
AbilityRuntime (POCO)ссылка на AbilityData, cooldownRemaining, готовностьactualCd = baseCd × Source.Get(CooldownEff)

Слоты по редкости (Common 1/1 → Rare 2/2 → Epic 3/3, док 6 §1) — это число элементов на реликвии; механика слотов одинакова.

2.5 Эфф-эффективности — данные уже есть, проводим логику

Enum StatType уже содержит ApplyBuffEff/ApplyDebuffEff/ReceiveBuffEff/ReceiveDebuffEff/CooldownEff/HealShieldDealtEff/HealShieldTakenEff/AbilityPower/MaxResource/StartResource/ResourceGainEff. Ф2 добавляет им дефолты/клампы в StatsConfig и проводку в формулы (§4), но не трогает enum (нумерация залочена).


3. Швы (Уровень 1 — фиксируем)

3.1 Кросс-сборочный шов компонентов (ГЛАВНЫЙ риск — §5 спайк)

Док 10 §2.2 заложил разрыв: сериализационный якорь — в Data, рантайм-хуки — в Combat.

// Data (низ): пустой маркер — Relic/Effect ссылаются, НЕ создавая Data→Combat вверх.
public interface IEffectComponent { }   // уже есть (Ф1)
 
// Combat (верх): рантайм-контракт. Контекст-типы (RuntimeUnit, ICombatContext) живут тут.
public interface IRuntimeEffectComponent : IEffectComponent
{
    void OnApply(in EffectContext ctx);
    void OnExpire(in EffectContext ctx);
}
public interface IPeriodicComponent : IRuntimeEffectComponent { float Interval { get; } void OnTick(in EffectContext ctx); }
public interface IReactiveComponent : IRuntimeEffectComponent { CombatEvent Events { get; } void OnEvent(in EffectContext ctx, in CombatEventData e); }
  • Конкретные компоненты (BurnComponent и т.д.) живут в Combat, реализуют IRuntimeEffectComponent.
  • EffectData._components (Data) типизирован IEffectComponent → хранит экземпляры Combat-типов полиморфно через [SerializeReference], без compile-зависимости Data→Combat.
  • EffectSystem (Combat) кастует Def.Components[i] к IRuntimeEffectComponent/IPeriodicComponent/IReactiveComponent.

Это работает, только если Unity/Odin умеет: (а) сериализовать Combat-тип в Data-поле, типизированное Data-интерфейсом, и (б) показать его в [SerializeReference]-дропдауне Odin. Не уверены → спайк ДО дизайна на этом (§5).

3.2 EffectContext (Combat) — передаётся in в компоненты

public readonly ref struct EffectContext   // или struct, решить в спайке alloc-профиля
{
    public RuntimeUnit   Target;
    public RuntimeUnit   Source;
    public ICombatContext Combat;
    public IRngService   Rng;
    public float         Dt;
    public int           Stacks;   // для масштаба от стаков
}

3.3 ICombatContext — наполняем стаб + добавляем

  • ApplyEffect(target, def, source)тело вместо no-op Ф1: резолв длительности (§4), стакинг, добавление RuntimeEffect в pending, OnApply компонентов, обновление EffectTagMask.
  • Heal(...) — добавить проводку HealShieldDealtEff × HealShieldTakenEff (сейчас Ф1 хилит «плоско»).
  • Новое: Dispel(in DispelRequest req) — разрешение по Polarity ∧ Tags ∧ CleanseTier ≤ DispelPower ∧ !Unremovable (док 6 §5.4).
  • QueryUnitsInRadius/SpawnProjectile/DealDamage — уже есть, переиспользуются компонентами.

3.4 Внутренняя event-queue (детерминизм реактивных компонентов)

Док 6 §7 / док 10 §5.6: отдельный механизм, не путать с командами и не с MessagePipe.

public enum CombatEvent { None, DamageDealt, DamageTaken, UnitDied, EffectApplied /*…*/ }  // [Flags] для подписки
public readonly struct CombatEventData { /* source, target, amount, … */ }
  • DealDamage/Heal/смерть энкьюят CombatEventData во внутреннюю FIFO-очередь.
  • Новый шаг тика DrainEventQueue() (сейчас в Tick его НЕТ): FIFO, диспатч в IReactiveComponent подписчиков (вампиризм/шипы). Реакция может породить ещё урон → ещё событие → дренится в той же очереди без рекурсии.
  • Outward-события для презентации (OnDamageDealt C#-event) остаются как в Ф1 — презентация только читает.

3.5 Порядок тика — куда вставляем (дельта к Ф1)

Tick(dt):
  FlushPendingSpawns
  ApplyDueCommands
  AbilitySystem.Tick      ← НОВОЕ: кулдауны↓, ресурс-реген, каст готовых активок
  Targeting → Movement → SpatialHash.Rebuild → AutoAttack → Projectiles
  EffectSystem.Tick       ← НОВОЕ: таймеры эффектов, OnTick периодики, expire, маска тегов
  DrainEventQueue         ← НОВОЕ: реактивные компоненты (FIFO, без рекурсии)
  Death → CheckOutcome → currentTick++
  • EffectSystem до Death — чтобы DoT мог добить.
  • DrainEventQueue после систем, до Death — реакции на урон тика учтены до проверки смертей.
  • Control-флаги читаются Movement/AutoAttack/AbilitySystem — порядок гарантирует, что эффект, наложенный командой/прошлым тиком, действует этим тиком.

4. Формулы (фиксируем — детерминизм + баланс)

4.1 Длительность при наложении (док 11 §5)

RemainingTime = base × Source.Get(ApplyEff[polarity]) × Target.Get(ReceiveEff[polarity])
   polarity=Buff   → ApplyBuffEff   × ReceiveBuffEff
   polarity=Debuff → ApplyDebuffEff × ReceiveDebuffEff
  • ReceiveDebuffEff скейлит и длительность контроля → роль tenacity (отдельного стата нет).
  • Neutral-полярность: какая пара применяется? → открытый вопрос §9 (склоняюсь: не скейлится вовсе).

4.2 Потенция — ОТДЕЛЬНО от длительности

  • Резолв параметра: ScalableValue.Resolve(Source.Stats) (база + Σ стат×ratio, напр. AbilityPower).
  • Урон/тик → обычный DamagePipeline (DamageDealtEff × митигация × DamageTakenEff).
  • Хил/щит/тик → × Source.HealShieldDealtEff × Target.HealShieldTakenEff.
  • DoT/HoT total = ratePerSecond × фактическая длительность (вытекает из §2.2, не запекать).

4.3 Кулдаун

actualCooldown = baseCooldown × Source.Get(CooldownEff)   // <1 = быстрее

5. Неизвестное → спайк (ДО дизайна, который на это опирается)

#НеизвестноеСпайк (одноразовый код)Если не выйдет
S1Кросс-сборочный [SerializeReference] (§3.1): сериализует ли Unity Combat-тип в Data-поле типа IEffectComponent, и видит ли Odin-дропдаун эти типыНа тест-EffectData положить 1 Combat-компонент через инспектор Odin, сохранить .asset, загрузить в EditMode-тесте, проверить что Components[0] is IRuntimeEffectComponent и поля не потерялись после domain reloadАльтернатива: компоненты в Data + рантайм-хуки через visitor/switch в Combat (хуже, но развязывает)
S2Реентрантность event-queue: реакция-на-урон порождает урон → не словить недетерминизм/переполнениеШипы + вампиризм на двух юнитах, прогнать N тиков headless, сверить checksum дважды (детерминизм) и убедиться в отсутствии stack overflowОграничить глубину дренажа на тик + лог-варнинг
S3EffectContext как ref struct vs struct — zero-alloc в горячем тикеПрогнать 100 эффектов × 1000 тиков, замерить аллокации профайлеромreadonly struct с копией

Правило пайплайна: упёрлись в S1 — это самый дорогой разворот всей фазы, поэтому он шаг №1 реализации (§7), а не «по ходу».


6. Классы и ответственность (без тел методов)

КлассСборкаЗа что отвечаетКлючевой публичный метод
EffectSystemCombatТик таймеров RuntimeEffect, OnTick периодики, expire + OnExpire, обновление EffectTagMask, стакинг при наложенииTick(units, ctx, dt), Apply(unit, def, source, ctx)
RuntimeEffectCombatPOCO-экземпляр (§2.2). Без интерфейса — одна реализация
AbilitySystemCombatКулдауны↓, реген ресурса, исполнение каста готовой активки (триггер «когда» — плейсхолдер в Ф2, AI в Ф3)Tick(units, ctx, dt), TryCast(unit, abilityIdx, ctx)
IRuntimeEffectComponent (+IPeriodic,IReactive)CombatПолиморфное поведение эффекта. Много реализаций (контент-словарь)OnApply/OnTick/OnEvent/OnExpire
Библиотека компонентовCombatStatModifierComponent, PeriodicDamageComponent, PeriodicHealComponent, ControlComponent, ShieldComponent, LifestealComponent(реакт.), ThornsComponent(реакт.), DispelComponent. Ф2 — по 1–2 представителя на тип для тестовпо интерфейсу
DamagePipelineCombatРасширить: эмит во внутреннюю event-queue (для реактивных) поверх Ф1-формулыExecute (есть)
StatsConfigDataДефолты/клампы Ф2-статов (эфф-эффективности старт 1.0, ресурс)поля
RuntimeUnitFactoryCombatШаги 5–6 сборки (док 6 §3): регистрация пассивок (GrantedEffectsApply с Duration=-1), активок (AbilityRuntime), CurrentResource = StartResourceBuild (есть, дополнить)

7. Контракт = тесты (имена, EditMode headless)

EffectSystem / жизненный цикл

  • Apply_AddsEffect_AndSetsTagMaskBit
  • Expire_RemovesEffect_AndClearsTagMaskBit_AndCallsOnExpire
  • PeriodicDamage_TicksEveryInterval_NotEveryFrame
  • DurationChange_ScalesTotalDot_RateStaysConstant (320 vs 160 при ×2 длительности)

Длительность / эфф-эффективности

  • ApplyDuration_ScaledBy_ApplyEff_TimesReceiveEff
  • NeutralPolarity_DurationNotScaled (фиксирует решение §9)
  • Tenacity_ReceiveDebuffEff_ShortensControl

Потенция

  • ScalableValue_Resolves_FromSourceStats_AtApplyTime
  • Heal_ScaledBy_HealShieldDealt_TimesTaken

Стакинг / контроль

  • Stack_RespectsMaxStacks
  • Refresh_ResetsDuration_KeepsStacks
  • Control_SetsCanActFalse_BlocksAutoAttack

Реактивные / event-queue (S2)

  • Lifesteal_HealsSource_FromDamageDealt
  • Thorns_DamageReflection_DoesNotRecurseInfinitely
  • EventQueue_DrainsFifo_DeterministicChecksum

Диспел

  • Dispel_RemovesMatchingPolarityAndTags_BelowDispelPower
  • Dispel_SkipsUnremovable_AndHigherCleanseTier

Способности

  • Cooldown_ScaledBy_CooldownEff
  • Cast_ConsumesResource_AndFailsWhenInsufficient
  • Passive_GrantedEffect_AppliedAtBuildWithInfiniteDuration

Спайк-гейт (S1)

  • SerializeReference_CombatComponent_SurvivesReloadInDataAsset

8. Порядок реализации внутри Фазы 2

  1. S1-спайк — кросс-сборочный [SerializeReference] (§5). Гейт всей фазы. Зелёный → дальше.
  2. Данные — дополнить EffectData (стакинг/диспел/EffectTag), RuntimeEffect, поля RuntimeUnit, дефолты Ф2-статов в StatsConfig.
  3. ШвыIRuntimeEffectComponent-иерархия, EffectContext, наполнить ApplyEffect, Heal-проводка эффективностей.
  4. EffectSystem + тик — вставить в порядок (§3.5), таймеры/периодика/expire/маска. Тесты жизненного цикла + длительности.
  5. Библиотека компонентов — StatMod → PeriodicDamage/Heal → Control → Shield (по 1–2 на тип). Тесты на каждый.
  6. Event-queue (S2) + реактивныеDrainEventQueue, Lifesteal/Thorns. Тесты детерминизма.
  7. ДиспелDispelComponent + ICombatContext.Dispel. Тесты.
  8. СпособностиAbilityData/AbilityRuntime/AbilitySystem, кулдаун/ресурс, плейсхолдер-триггер каста. Тесты.
  9. Интеграция — пассивки/активки через RuntimeUnitFactory; 2–3 тест-реликвии с эффектами; battle-integration с DoT-добиванием.
  10. Презентация (минимум) — переиспользовать Ф1-хуки: тики DoT-цифр через OnDamageDealt, без новых экранов (полноценные иконки баффов — Ф7).

Темп (док 9): после шага 4 — «эффекты тикают» в headless-тесте; после 8 — кастующиеся способности; видимый слой — минимальный, полировка в Ф7.


9. Открытые вопросы (на ревью)

  • Полный список EffectTag ([Flags]): категории для диспела/AI-фильтров/маски. Минимум: Control/DoT/HoT/Heal/Shield/Buff/Debuff. Финализировать перед шагом 2.
  • EffectPolarity.Neutral — какая пара эфф-эффективностей? Предлагаю: Neutral длительность не скейлится (тест NeutralPolarity_DurationNotScaled это и фиксирует). Согласовать.
  • Триггер каста в Ф2 (до AI Ф3): «кастовать готовую активку на ближайшего по targetMode» детерминированно — достаточно для тестов? Или только через CastGuildmasterSpellCommand/тест-команду? Предлагаю простой детерминированный плейсхолдер + тест-команду; настоящий выбор — Ф3.
  • Модель ресурса (ResourceRegen зарезервирован в доке 11): мана регенит по времени, ярость копится от боя. Ф2 — минимум (StartResource, трата на каст, простой реген по ResourceGainEff)? Или ярость отложить в Ф4? Уточнить.
  • RemainingTime в секундах или тиках? Сим тикает фикс. dt → тики проще для детерминизма; секунды нагляднее в авторинге. Предлагаю тики внутри, конверсия BaseDuration(сек)→тики при наложении.
  • EffectContext: ref struct vs struct — решается профилем в S3.

10. Следующий шаг

Макс читает → правки → по апруву начинаем с S1-спайка (§7 шаг 1): кросс-сборочный [SerializeReference]. Зелёный гейт — разворачиваем данные и EffectSystem. После фазы — ретро дизайна (что разошлось с планом) на вход Фазы 3 (AI-движок).


11. Ретро реализации (что разошлось с планом)

Фаза закрыта. Дельты к плану — на вход Фазе 3:

  • Шаг 9 — пассивки применяет RuntimeUnitFactory (как и планировалось §6), но это потребовало дать ей EffectSystem + ICombatContext. Причина: порядок сборки §5.2 (пассивки → CurrentHP = Get(MaxHP)) обязателен — пассив на +MaxHP должен поднимать и стартовое HP, поэтому наложение происходит внутри Create до инициализации CurrentHP, а не на спавне в CombatSimulation. DI (CombatLifetimeScope) резолвит сим как контекст в фабрику; цикла нет (сим не зависит от фабрики). Источник пассивки — сам юнит.
  • Шаг 9 — ресурс: CurrentResource = Stats.Get(StartResource) при сборке (без клампа к MaxResource — баланс в Ф4). Активки оборачиваются в AbilityRuntime тут же.
  • Шаг 10 — презентации нового кода не понадобилось. DoT идёт PeriodicDamageComponent → DealDamage → OnDamageDealt, т.е. цифры урона переиспользуют Ф1-хук без изменений. Доказано тестом DotTicks_ReuseOnDamageDealtHook_ForPresentation. Иконки баффов/HoT-числа — Ф7, как и планировалось.
  • Тесты интеграции: RuntimeUnitFactoryTests (пассивка с ∞-длительностью при сборке, +MaxHP до CurrentHP, сборка активок, старт ресурса) + DotBattleIntegrationTests (DoT-добивание завершает бой; хук презентации).