Решили: «чем исполнен приём» — поле навыка (AbilityData.CastSource: Auto / OffHand / BothHands / Shield / WholeBody / None), а лента боя возит СAМО определение способности payload’ом, как уже возит EffectData. Показ переводит объявленную РОЛЬ в части конкретного тела (CastGlowMaskIUnitPartLookup).

Почему: событие каста доезжало до показа как один casterId, поэтому телеграф светил всем одинаково — щит-бэш зажигал клинок. Отвергнуто:

  • вывести природу приёма из механики (есть урон → удар оружием, есть канал → бафф) — это та же эвристика, которую мы только что выпилили из типа предмета: приём с уроном бывает и пинком;
  • везти по ленте узкий слепок (enum источника вместо определения) — дешевле на байтах, но показу от навыка нужно не только это (школа, цвет, природа), и слепок пришлось бы расширять каждый раз; прецедент с EffectData уже показал, что ссылка на неизменное определение здесь достаточна;
  • резолвить часть в симуляции — сим не знает и не должен знать про части тела и свечение.

Роль названа в терминах боя, а не рига (OffHand, а не «узел Grip слева»): у дуалиста это второй кинжал, у щитовика — щит, у монаха — кулак. Один и тот же навык ложится на любую расстановку.

Грабли: запрос «источник удара ЭТОЙ рукой» падал в фолбэк «любое оружие», и у бойца с мечом справа и щитом слева удар левой светил меч из другой руки. Тест поймал сразу; правило теперь такое: явный адрес строгий, терпим только Auto — и он обязан быть терпимым, иначе левша (оружие лишь в левой) остался бы без телеграфа.

Владелец правды: CastSource.cs (перечень ролей), CastGlowMask.cs (перевод роли в части), BattleTape.RecordAbility/GetAbility (доставка), тесты CastGlowMaskTests (перевод на четырёх расстановках) и TapeCastPayloadTests (определение доезжает до показа и не путается между кастами).