Fix battle card art, mulligan, damage targeting and rest
CI / test (push) Successful in 1m49s
Details
CI / test (push) Successful in 1m49s
Details
This commit is contained in:
parent
f5b7f3e2d8
commit
c0c00ccc4c
|
|
@ -275,9 +275,13 @@ drive/damage-анимации и звук — следующие. Финальн
|
||||||
|
|
||||||
- [x] Поле: `R V R` / `R R R` у каждого игрока, damage/soul слева,
|
- [x] Поле: `R V R` / `R R R` у каждого игрока, damage/soul слева,
|
||||||
колода/дроп справа, зеркально у противника, зона защиты между полями.
|
колода/дроп справа, зеркально у противника, зона защиты между полями.
|
||||||
- [x] Карты: реальный арт из `imageKey`, рамка клана, рубашка, имя, ранг,
|
- [x] Карты: реальный арт на всю карту, рамка клана, рубашка, имя, ранг,
|
||||||
триггер, сила/крит, REST/скрытая карта; процедурный fallback.
|
триггер, сила/крит; REST поворачивает карту на бок; процедурный fallback.
|
||||||
- [x] Старт: маллиган (сервер пока даёт только «закончить маллиган»).
|
- [x] Старт: маллиган с выбором карт в руке, возвратом в колоду,
|
||||||
|
перемешиванием и добиром до пяти.
|
||||||
|
- [x] Зона урона: видимые карты на боку; heal и Counter Blast выбирают
|
||||||
|
карту урона кликом.
|
||||||
|
- [x] Позванные rear-guard'ы входят стоящими; rest — после атаки/буста.
|
||||||
- [x] Ввод: кнопки легальных интентов; клик по картам/зонам,
|
- [x] Ввод: кнопки легальных интентов; клик по картам/зонам,
|
||||||
подсветка целей, группировка действий, выбор силы/эффекта триггера
|
подсветка целей, группировка действий, выбор силы/эффекта триггера
|
||||||
и журнал боя.
|
и журнал боя.
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
|
| CI (Forgejo Actions) | готово: `ci.yml` проходит на `home-runner` |
|
||||||
| EF-миграции | готово (`InitialCreate`) |
|
| EF-миграции | готово (`InitialCreate`) |
|
||||||
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
|
| Orleans | готово: silo + `MatchActorGrain`, включается `Orleans:Enabled` |
|
||||||
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с реальными артами, анимациями и RU/EN |
|
| Клиент | Unity 6.6: 3D-сцены, логин, онбординг, коллекция, декбилдер, магазин, история/реплей, очередь, тренировка с ботом, бой с артами, маллиганом, зоной урона, анимациями и RU/EN |
|
||||||
| Лаунчер | не начато |
|
| Лаунчер | не начато |
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -72,6 +72,7 @@ namespace Vanguard.UI
|
||||||
private int _pendingBoosterSlot = -1;
|
private int _pendingBoosterSlot = -1;
|
||||||
private int? _triggerPowerSlot;
|
private int? _triggerPowerSlot;
|
||||||
private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
|
private readonly HashSet<int> _mulliganSelected = new HashSet<int>();
|
||||||
|
private int _abilityDamageIndex = -1;
|
||||||
private readonly Dictionary<string, RectTransform> _cardPanels =
|
private readonly Dictionary<string, RectTransform> _cardPanels =
|
||||||
new Dictionary<string, RectTransform>(StringComparer.Ordinal);
|
new Dictionary<string, RectTransform>(StringComparer.Ordinal);
|
||||||
private readonly List<CardAnimation> _cardAnimations = new List<CardAnimation>();
|
private readonly List<CardAnimation> _cardAnimations = new List<CardAnimation>();
|
||||||
|
|
@ -207,6 +208,7 @@ namespace Vanguard.UI
|
||||||
_pendingIntents.Clear();
|
_pendingIntents.Clear();
|
||||||
_highlightKeys.Clear();
|
_highlightKeys.Clear();
|
||||||
_mulliganSelected.Clear();
|
_mulliganSelected.Clear();
|
||||||
|
_abilityDamageIndex = -1;
|
||||||
_pendingBoosterSlot = -1;
|
_pendingBoosterSlot = -1;
|
||||||
_triggerPowerSlot = null;
|
_triggerPowerSlot = null;
|
||||||
_showAllIntents = false;
|
_showAllIntents = false;
|
||||||
|
|
@ -963,6 +965,15 @@ namespace Vanguard.UI
|
||||||
if (_sending || _connection == null || _cts == null || _view == null)
|
if (_sending || _connection == null || _cts == null || _view == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (_abilityDamageIndex >= 0)
|
||||||
|
{
|
||||||
|
if (intent is ActivateAbilityIntent activate)
|
||||||
|
activate.DamageIndex = _abilityDamageIndex;
|
||||||
|
else if (intent is ResolveAbilityIntent resolve)
|
||||||
|
resolve.DamageIndex = _abilityDamageIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
_abilityDamageIndex = -1;
|
||||||
_pendingIntents.Clear();
|
_pendingIntents.Clear();
|
||||||
_highlightKeys.Clear();
|
_highlightKeys.Clear();
|
||||||
_pendingBoosterSlot = -1;
|
_pendingBoosterSlot = -1;
|
||||||
|
|
@ -1215,16 +1226,17 @@ namespace Vanguard.UI
|
||||||
gridElement.preferredHeight = 176f;
|
gridElement.preferredHeight = 176f;
|
||||||
|
|
||||||
var damageChoice = IsDamageChoice();
|
var damageChoice = IsDamageChoice();
|
||||||
|
var abilityTargeting = IsAbilityDamageTargeting();
|
||||||
var displayIndex = 0;
|
var displayIndex = 0;
|
||||||
|
|
||||||
for (var i = 0; i < player.FaceDownDamage; i++, displayIndex++)
|
for (var i = 0; i < player.FaceDownDamage; i++, displayIndex++)
|
||||||
{
|
{
|
||||||
AddDamageCard(grid, displayIndex, null, false, damageChoice);
|
AddDamageCard(grid, displayIndex, null, false, damageChoice, abilityTargeting);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var card in player.FaceUpDamage)
|
foreach (var card in player.FaceUpDamage)
|
||||||
{
|
{
|
||||||
AddDamageCard(grid, displayIndex, card, true, damageChoice);
|
AddDamageCard(grid, displayIndex, card, true, damageChoice, abilityTargeting: false);
|
||||||
displayIndex++;
|
displayIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1234,7 +1246,8 @@ namespace Vanguard.UI
|
||||||
int displayIndex,
|
int displayIndex,
|
||||||
CardView card,
|
CardView card,
|
||||||
bool faceUp,
|
bool faceUp,
|
||||||
bool damageChoice)
|
bool damageChoice,
|
||||||
|
bool abilityTargeting)
|
||||||
{
|
{
|
||||||
var panel = UiKit.CreatePanel(parent, "Damage_" + displayIndex, Color.white);
|
var panel = UiKit.CreatePanel(parent, "Damage_" + displayIndex, Color.white);
|
||||||
var rect = panel;
|
var rect = panel;
|
||||||
|
|
@ -1267,19 +1280,23 @@ namespace Vanguard.UI
|
||||||
image.color = Color.white;
|
image.color = Color.white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var canClick = damageChoice || abilityTargeting;
|
||||||
|
var selected = abilityTargeting && _abilityDamageIndex == displayIndex;
|
||||||
var outline = panel.gameObject.AddComponent<Outline>();
|
var outline = panel.gameObject.AddComponent<Outline>();
|
||||||
outline.effectColor = damageChoice
|
outline.effectColor = selected
|
||||||
? new Color(0.98f, 0.82f, 0.42f)
|
? new Color(0.45f, 0.95f, 0.62f)
|
||||||
: new Color(0.42f, 0.18f, 0.18f);
|
: canClick
|
||||||
outline.effectDistance = damageChoice
|
? new Color(0.98f, 0.82f, 0.42f)
|
||||||
? new Vector2(3f, -3f)
|
: new Color(0.42f, 0.18f, 0.18f);
|
||||||
|
outline.effectDistance = canClick
|
||||||
|
? new Vector2(selected ? 4f : 3f, selected ? -4f : -3f)
|
||||||
: new Vector2(1f, -1f);
|
: new Vector2(1f, -1f);
|
||||||
|
|
||||||
var button = panel.gameObject.AddComponent<Button>();
|
var button = panel.gameObject.AddComponent<Button>();
|
||||||
button.targetGraphic = image;
|
button.targetGraphic = image;
|
||||||
var capturedIndex = displayIndex;
|
var capturedIndex = displayIndex;
|
||||||
button.onClick.AddListener(() => OnDamageClicked(capturedIndex));
|
button.onClick.AddListener(() => OnDamageClicked(capturedIndex));
|
||||||
button.interactable = damageChoice;
|
button.interactable = canClick;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsDamageChoice() =>
|
private bool IsDamageChoice() =>
|
||||||
|
|
@ -1287,19 +1304,46 @@ namespace Vanguard.UI
|
||||||
&& _view.Choice.Prompt != null
|
&& _view.Choice.Prompt != null
|
||||||
&& _view.Choice.Prompt.IndexOf("урон", StringComparison.OrdinalIgnoreCase) >= 0;
|
&& _view.Choice.Prompt.IndexOf("урон", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
|
||||||
|
private bool IsAbilityDamageTargeting()
|
||||||
|
{
|
||||||
|
if (_view == null || _view.Choice != null || _view.ActivePlayer != _view.You)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var self = _view.Players.FirstOrDefault(player => player.Index == _view.You);
|
||||||
|
if (self == null || self.FaceDownDamage == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return (_view.LegalIntents ?? new List<IntentPayload>())
|
||||||
|
.Any(intent => intent is ActivateAbilityIntent or ResolveAbilityIntent);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDamageClicked(int displayIndex)
|
private void OnDamageClicked(int displayIndex)
|
||||||
{
|
{
|
||||||
if (_view?.Choice?.Options == null || !IsDamageChoice())
|
if (IsDamageChoice())
|
||||||
|
{
|
||||||
|
if (_view?.Choice?.Options == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (var i = 0; i < _view.Choice.Options.Count; i++)
|
||||||
|
{
|
||||||
|
if (_view.Choice.Options[i].Slot == displayIndex)
|
||||||
|
{
|
||||||
|
OnIntentClicked(new ChooseIntent { Index = i });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsAbilityDamageTargeting())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
for (var i = 0; i < _view.Choice.Options.Count; i++)
|
_abilityDamageIndex = displayIndex;
|
||||||
{
|
SetStatus(
|
||||||
if (_view.Choice.Options[i].Slot == displayIndex)
|
"Для Counter Blast выбрана карта урона " + (displayIndex + 1) + ".",
|
||||||
{
|
new Color(0.45f, 0.95f, 0.62f));
|
||||||
OnIntentClicked(new ChooseIntent { Index = i });
|
Render();
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddPileColumn(Transform parent, PlayerView player)
|
private void AddPileColumn(Transform parent, PlayerView player)
|
||||||
|
|
@ -2054,6 +2098,19 @@ namespace Vanguard.UI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IsAbilityDamageTargeting())
|
||||||
|
{
|
||||||
|
var abilityHint = UiKit.CreateText(
|
||||||
|
_intents,
|
||||||
|
"AbilityDamageHint",
|
||||||
|
"Выберите закрытую карту урона для Counter Blast (необязательно).",
|
||||||
|
15,
|
||||||
|
new Color(0.72f, 0.80f, 0.94f),
|
||||||
|
TextAnchor.UpperLeft);
|
||||||
|
abilityHint.horizontalOverflow = HorizontalWrapMode.Wrap;
|
||||||
|
UiKit.SetPreferredHeight(abilityHint, 34f);
|
||||||
|
}
|
||||||
|
|
||||||
if (board.Count > 0)
|
if (board.Count > 0)
|
||||||
{
|
{
|
||||||
var hint = UiKit.CreateText(
|
var hint = UiKit.CreateText(
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,12 @@ namespace Vanguard.UI
|
||||||
["Выберите одну триал-деку. 50 карт попадут в коллекцию, плюс 5 токенов на бустеры."] = "Choose one trial deck. 50 cards will be added to your collection, plus 5 tokens for boosters.",
|
["Выберите одну триал-деку. 50 карт попадут в коллекцию, плюс 5 токенов на бустеры."] = "Choose one trial deck. 50 cards will be added to your collection, plus 5 tokens for boosters.",
|
||||||
["Unity блокирует HTTP. Player Settings → Allow downloads over HTTP = Always, затем перезапусти Unity."] = "Unity blocks HTTP. Player Settings → Allow downloads over HTTP = Always, then restart Unity.",
|
["Unity блокирует HTTP. Player Settings → Allow downloads over HTTP = Always, затем перезапусти Unity."] = "Unity blocks HTTP. Player Settings → Allow downloads over HTTP = Always, then restart Unity.",
|
||||||
["VanguardApp не создан. Запусти игру из Boot или через Play в любой сцене клиента."] = "VanguardApp was not created. Start the game from Boot or via Play in any client scene.",
|
["VanguardApp не создан. Запусти игру из Boot или через Play в любой сцене клиента."] = "VanguardApp was not created. Start the game from Boot or via Play in any client scene.",
|
||||||
|
["Кликните карты, которые вернутся в колоду."] = "Click the cards you want to return to the deck.",
|
||||||
|
["К обмену выбрано карт: "] = "Cards selected for mulligan: ",
|
||||||
|
["Закрытая карта"] = "Face-down card",
|
||||||
|
["Выберите закрытую карту урона для Counter Blast (необязательно)."] = "Choose a face-down damage card for Counter Blast (optional).",
|
||||||
|
["Для Counter Blast выбрана карта урона "] = "Damage card selected for Counter Blast: ",
|
||||||
|
["УРОН · "] = "DAMAGE · ",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly KeyValuePair<string, string>[] Phrases = Exact
|
private static readonly KeyValuePair<string, string>[] Phrases = Exact
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.EventSystems;
|
using UnityEngine.EventSystems;
|
||||||
|
|
@ -8,8 +10,10 @@ using Vanguard.Api.Contracts;
|
||||||
using Vanguard.App;
|
using Vanguard.App;
|
||||||
using Vanguard.Auth;
|
using Vanguard.Auth;
|
||||||
using Vanguard.Meta;
|
using Vanguard.Meta;
|
||||||
|
using Vanguard.Shared.Protocol;
|
||||||
using Vanguard.State;
|
using Vanguard.State;
|
||||||
using Vanguard.UI;
|
using Vanguard.UI;
|
||||||
|
using BattleView = Vanguard.UI.BattleView;
|
||||||
|
|
||||||
namespace Vanguard.Tests
|
namespace Vanguard.Tests
|
||||||
{
|
{
|
||||||
|
|
@ -115,6 +119,100 @@ namespace Vanguard.Tests
|
||||||
Object.DestroyImmediate(go);
|
Object.DestroyImmediate(go);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Battle_Renders_Damage_And_Rested_Cards()
|
||||||
|
{
|
||||||
|
var config = new AppConfig();
|
||||||
|
config.Load();
|
||||||
|
var tokens = new TokenStore();
|
||||||
|
var api = new ApiClient(config, tokens);
|
||||||
|
var player = new PlayerState();
|
||||||
|
var auth = new AuthService(api, tokens, player);
|
||||||
|
var meta = new MetaService(api, player);
|
||||||
|
var canvas = UiKit.CreateCanvas("Test Canvas");
|
||||||
|
|
||||||
|
var battle = new BattleView(canvas.transform, meta, () => { });
|
||||||
|
var screen = Resources.FindObjectsOfTypeAll<GameObject>()
|
||||||
|
.First(go => go.name == "BattleScreen");
|
||||||
|
screen.SetActive(true);
|
||||||
|
|
||||||
|
CardView Card(string id, int instance, bool rested = false) => new CardView
|
||||||
|
{
|
||||||
|
PrintingId = id,
|
||||||
|
InstanceId = instance,
|
||||||
|
Rested = rested,
|
||||||
|
FaceUp = true,
|
||||||
|
Power = 8000,
|
||||||
|
BasePower = 8000,
|
||||||
|
Critical = 1,
|
||||||
|
BaseCritical = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
var self = new PlayerView
|
||||||
|
{
|
||||||
|
Index = 0,
|
||||||
|
Name = "Me",
|
||||||
|
Vanguard = Card("BT01/001EN", 1),
|
||||||
|
RearGuards = new CardView?[5],
|
||||||
|
Hand = new List<CardView> { Card("BT01/042EN", 2) },
|
||||||
|
Soul = new List<CardView> { Card("BT01/003EN", 3) },
|
||||||
|
FaceDownDamage = 2,
|
||||||
|
FaceUpDamage = new List<CardView> { Card("BT01/056EN", 4) },
|
||||||
|
DeckCount = 40,
|
||||||
|
Drop = new List<CardView>(),
|
||||||
|
};
|
||||||
|
self.RearGuards[3] = Card("BT01/042EN", 5, rested: true);
|
||||||
|
|
||||||
|
var opponent = new PlayerView
|
||||||
|
{
|
||||||
|
Index = 1,
|
||||||
|
Name = "Opponent",
|
||||||
|
Vanguard = Card("BT01/001EN", 6),
|
||||||
|
RearGuards = new CardView?[5],
|
||||||
|
Hand = new List<CardView>(),
|
||||||
|
Soul = new List<CardView>(),
|
||||||
|
FaceDownDamage = 1,
|
||||||
|
FaceUpDamage = new List<CardView>(),
|
||||||
|
DeckCount = 40,
|
||||||
|
Drop = new List<CardView>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var match = new MatchView
|
||||||
|
{
|
||||||
|
MatchId = "render-test",
|
||||||
|
You = 0,
|
||||||
|
ActivePlayer = 0,
|
||||||
|
Phase = GamePhase.Main,
|
||||||
|
Players = new List<PlayerView> { self, opponent },
|
||||||
|
Clock = new ClockView(),
|
||||||
|
LegalIntents = new List<IntentPayload>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
typeof(BattleView)
|
||||||
|
.GetField("_view", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.SetValue(battle, match);
|
||||||
|
typeof(BattleView)
|
||||||
|
.GetMethod("Render", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.Invoke(battle, null);
|
||||||
|
|
||||||
|
Assert.IsTrue(Exists("DamageZone"));
|
||||||
|
Assert.IsTrue(Exists("Damage_0"));
|
||||||
|
Assert.IsTrue(Exists("Damage_1"));
|
||||||
|
Assert.IsTrue(Exists("Damage_2"));
|
||||||
|
Assert.IsTrue(Exists("Card_R4"));
|
||||||
|
var hasSidewaysCard = Resources.FindObjectsOfTypeAll<GameObject>()
|
||||||
|
.Where(go => go.name == "Card_R4")
|
||||||
|
.Any(go =>
|
||||||
|
{
|
||||||
|
var angle = go.transform.localEulerAngles.z;
|
||||||
|
return Mathf.Abs(Mathf.DeltaAngle(angle, 90f)) < 1f
|
||||||
|
|| Mathf.Abs(Mathf.DeltaAngle(angle, -90f)) < 1f;
|
||||||
|
});
|
||||||
|
Assert.IsTrue(hasSidewaysCard, "Rested card was not turned sideways");
|
||||||
|
|
||||||
|
Cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
private static bool Exists(string name) =>
|
private static bool Exists(string name) =>
|
||||||
Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == name);
|
Resources.FindObjectsOfTypeAll<GameObject>().Any(go => go.name == name);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@
|
||||||
],
|
],
|
||||||
"excludePlatforms": [],
|
"excludePlatforms": [],
|
||||||
"allowUnsafeCode": false,
|
"allowUnsafeCode": false,
|
||||||
"overrideReferences": false,
|
"overrideReferences": true,
|
||||||
"precompiledReferences": [],
|
"precompiledReferences": [
|
||||||
|
"Vanguard.Shared.dll"
|
||||||
|
],
|
||||||
"autoReferenced": false,
|
"autoReferenced": false,
|
||||||
"defineConstraints": [],
|
"defineConstraints": [],
|
||||||
"versionDefines": [],
|
"versionDefines": [],
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,11 @@ http://localhost:5080
|
||||||
действия, выбор силы/эффекта триггера, журнал событий и подсказки;
|
действия, выбор силы/эффекта триггера, журнал событий и подсказки;
|
||||||
event-анимации с очередью и скипом.
|
event-анимации с очередью и скипом.
|
||||||
- Карты в бою и коллекции: реальные арты, рамка по клану, имя, ранг,
|
- Карты в бою и коллекции: реальные арты, рамка по клану, имя, ранг,
|
||||||
триггер, сила/крит, REST и рубашка для скрытых карт; call/ride-въезд,
|
триггер, сила/крит, REST поворотом на бок и рубашка для скрытых карт;
|
||||||
пульс атаки и призрак ухода в дроп.
|
call/ride-въезд, пульс атаки и призрак ухода в дроп.
|
||||||
|
- Зона урона: видимые карты на боку; heal выбирает карту урона, Counter
|
||||||
|
Blast принимает выбранную закрытую карту. Маллиган — выбор карт в руке
|
||||||
|
с обменом и добиром до пяти.
|
||||||
- Реальные арты: `CardImageService` ищет `Resources/CardArt/{imageKey}`;
|
- Реальные арты: `CardImageService` ищет `Resources/CardArt/{imageKey}`;
|
||||||
`imageKey` приходит из `/api/cards`. Скачаны все **124** арта;
|
`imageKey` приходит из `/api/cards`. Скачаны все **124** арта;
|
||||||
импорт — `tools/import_card_art.py`.
|
импорт — `tools/import_card_art.py`.
|
||||||
|
|
@ -134,5 +137,5 @@ Smoke сцен (входит в Play Mode, открывает Boot → Login, п
|
||||||
```
|
```
|
||||||
|
|
||||||
Тесты не ходят в сеть: проверяются composition root, создание Canvas,
|
Тесты не ходят в сеть: проверяются composition root, создание Canvas,
|
||||||
экранов, импорт арта и локализация (статические строки, динамика,
|
экранов, импорт арта, локализация (статические строки, динамика,
|
||||||
live-переключение). Сейчас **3/3**.
|
live-переключение) и рендер боевой доски с уроном/rest. Сейчас **4/4**.
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ MVP: бустер **BT01** + триал-деки **TD01/TD02** — 94 карты
|
||||||
|
|
||||||
| Проверка | Результат |
|
| Проверка | Результат |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Тесты сервера | **177/177** (132 правила/контракты + 45 сервер) |
|
| Тесты сервера | **180/180** (135 правил/контрактов + 45 сервер) |
|
||||||
| `validate` | 0 ошибок, 0 предупреждений |
|
| `validate` | 0 ошибок, 0 предупреждений |
|
||||||
| `translations check` | 94/94 |
|
| `translations check` | 94/94 |
|
||||||
| `stress 1000` | 1000/1000 партий, 0 ошибок |
|
| `stress 1000` | 1000/1000 партий, 0 ошибок |
|
||||||
|
|
@ -72,7 +72,7 @@ dotnet run --project server\src\Vanguard.Cli -- stress 1000
|
||||||
Ожидаемо:
|
Ожидаемо:
|
||||||
|
|
||||||
```
|
```
|
||||||
Пройден! : не пройдено 0, пройдено 132 (Vanguard.Rules.Tests)
|
Пройден! : не пройдено 0, пройдено 135 (Vanguard.Rules.Tests)
|
||||||
Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests)
|
Пройден! : не пройдено 0, пройдено 45 (Vanguard.Server.Tests)
|
||||||
OK: ошибок 0, предупреждений 0
|
OK: ошибок 0, предупреждений 0
|
||||||
Переведено: 94/94
|
Переведено: 94/94
|
||||||
|
|
@ -790,3 +790,28 @@ Battle; Boot и общие сервисы остаются персистент
|
||||||
- Ограничения: звука пока нет; полноценные drive/damage-анимации карт и
|
- Ограничения: звука пока нет; полноценные drive/damage-анимации карт и
|
||||||
стола — следующий блок; локализация сделана runtime-словарём, без
|
стола — следующий блок; локализация сделана runtime-словарём, без
|
||||||
Unity Localization-таблиц (пакет остаётся подключён).
|
Unity Localization-таблиц (пакет остаётся подключён).
|
||||||
|
|
||||||
|
## 2026-10-22 — боевые карты, маллиган, урон
|
||||||
|
|
||||||
|
- Боевые карты переделаны: арт растянут на всю карту, сверху слот/грейд,
|
||||||
|
снизу плашка с именем и P/C/триггером; в руке — компактный вариант.
|
||||||
|
`CardImageService` проверен тестом: `td01_005` реально загружается.
|
||||||
|
- Rest теперь визуально поворачивает карту на бок; удалён старый текстовый
|
||||||
|
маркер `REST`. Позванные rear-guard'ы входят стоящими (`Call`,
|
||||||
|
`CallFromSoul`, `CallFromDeck` исправлены на `Rested = false`).
|
||||||
|
- Зона урона: отдельная сетка видимых карт на боку, до 6 штук; закрытые
|
||||||
|
показываются рубашкой, открытые — артом. При выборе карты урона (heal или
|
||||||
|
Counter Blast) карты подсвечиваются и кликаются.
|
||||||
|
- Heal: сервер больше не забирает последнюю карту автоматически, а создаёт
|
||||||
|
`ChoiceTargetKind.Damage`; клиент выбирает карту кликом. Counter Blast
|
||||||
|
принимает `DamageIndex` в `ActivateAbilityIntent`/`ResolveAbilityIntent`
|
||||||
|
и использует выбранную закрытую карту (остаток суммы — авто).
|
||||||
|
- Маллиган: клиент выбирает карты в руке, подсвечивает их и отправляет
|
||||||
|
`MulliganIntent`; сервер возвращает выбранные в колоду, перемешивает и
|
||||||
|
добирает столько же. После обмена доступно «Закончить маллиган».
|
||||||
|
- Протокол `Vanguard.Shared` расширен (`DamageIndex`, `ChoiceTargetKind`),
|
||||||
|
DLL пересобран и обновлён в `client/Assets/Plugins/Vanguard.Shared`.
|
||||||
|
- Проверки: сервер **135** правил + **45** серверных; клиент EditMode
|
||||||
|
**4/4** (включая рендер доски с уроном и отдыхающей картой);
|
||||||
|
`SCENE_SMOKE success=True active=Login`; `validate` и
|
||||||
|
`translations check` — без ошибок.
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
матчмейкинг, WebSocket-матч, история, реплеи.
|
матчмейкинг, WebSocket-матч, история, реплеи.
|
||||||
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
|
- Orleans включается `Orleans:Enabled`; в compose включён. Один silo,
|
||||||
in-process реестр соединений.
|
in-process реестр соединений.
|
||||||
- Тесты: **132** правил + **45** серверных, CI зелёный.
|
- Тесты: **135** правил + **45** серверных, CI зелёный.
|
||||||
- Стенд: `http://192.168.31.158:18080`, `/health` → ok.
|
- Стенд: `http://192.168.31.158:18080`, `/health` → ok.
|
||||||
- Документация: `docs/server-api.md`, `docs/protocol.md`,
|
- Документация: `docs/server-api.md`, `docs/protocol.md`,
|
||||||
`docs/HANDOFF.md`.
|
`docs/HANDOFF.md`.
|
||||||
|
|
@ -61,11 +61,17 @@
|
||||||
- Тренировка с ботом: кнопка «С ботом» в редакторе колоды; сервер
|
- Тренировка с ботом: кнопка «С ботом» в редакторе колоды; сервер
|
||||||
создаёт casual-матч против случайного бота (`POST /api/game/practice`),
|
создаёт casual-матч против случайного бота (`POST /api/game/practice`),
|
||||||
без истории, MMR и наград.
|
без истории, MMR и наград.
|
||||||
- Боевой экран: реальные арты карт, рамка по клану, имя, ранг, триггер,
|
- Боевой экран: реальный арт на всю карту, рамка по клану, имя, ранг,
|
||||||
сила/крит, REST и рубашка; процедурные плашки остаются fallback.
|
триггер, сила/крит и рубашка; fallback — процедурная плашка.
|
||||||
|
Rest поворачивает карту на бок.
|
||||||
- Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона
|
- Поле: у каждого игрока два ряда `R V R` / `R R R`, слева зона урона
|
||||||
(+soul), справа колода и ниже дроп; у противника зеркально; между
|
(+soul), справа колода и ниже дроп; у противника зеркально; между
|
||||||
полями — зона защиты со щитом и проверками.
|
полями — зона защиты со щитом и проверками.
|
||||||
|
- Зона урона: видимые карты на боку (закрытые — рубашкой, открытые —
|
||||||
|
артом). Heal предлагает выбрать карту урона; Counter Blast принимает
|
||||||
|
выбранную закрытую карту из зоны урона.
|
||||||
|
- Маллиган: клик по картам в руке выбирает их, отдельная кнопка меняет
|
||||||
|
выбранные карты; новые карты добираются до пяти, колода перемешивается.
|
||||||
- Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх
|
- Анимации: event-фидбек (атака, guard, drive, урон, retire, heal) поверх
|
||||||
стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации».
|
стола, не блокирует ввод; очередь событий + кнопка «Пропустить анимации».
|
||||||
Карты въезжают из руки/колоды при call/ride, атакующий и цель пульсируют,
|
Карты въезжают из руки/колоды при call/ride, атакующий и цель пульсируют,
|
||||||
|
|
@ -75,7 +81,8 @@
|
||||||
`tools/import_card_art.py`.
|
`tools/import_card_art.py`.
|
||||||
- Взаимодействие в бою: клик по картам/зонам, подсветка целей,
|
- Взаимодействие в бою: клик по картам/зонам, подсветка целей,
|
||||||
группировка действий (карты → цели, глобальные кнопки отдельно),
|
группировка действий (карты → цели, глобальные кнопки отдельно),
|
||||||
выбор силы/эффекта триггера, журнал последних событий.
|
выбор силы/эффекта триггера, журнал последних событий. Позванные
|
||||||
|
rear-guard'ы входят в стоячем положении; rest — только после атаки/буста.
|
||||||
- Отдельная сцена `Collection` и `CollectionView` удалены; коллекция
|
- Отдельная сцена `Collection` и `CollectionView` удалены; коллекция
|
||||||
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
|
открывается из Lobby кнопкой «Коллекция» (сцена `DeckBuilder`).
|
||||||
- Не готово: 3D-модели, звук, настройки (язык уже есть), DOTween,
|
- Не готово: 3D-модели, звук, настройки (язык уже есть), DOTween,
|
||||||
|
|
@ -111,9 +118,9 @@ curl http://127.0.0.1:18080/health
|
||||||
-executeMethod Vanguard.Editor.SceneSmoke.Run
|
-executeMethod Vanguard.Editor.SceneSmoke.Run
|
||||||
```
|
```
|
||||||
|
|
||||||
Клиентские проверки: **3/3** EditMode (сборка всех экранов, импорт арта,
|
Клиентские проверки: **4/4** EditMode (сборка всех экранов, импорт арта,
|
||||||
перевод статических/динамических строк и live-переключение языка),
|
перевод статических/динамических строк, live-переключение языка, рендер
|
||||||
`SCENE_SMOKE success=True`.
|
доски с уроном и отдыхающей картой), `SCENE_SMOKE success=True`.
|
||||||
|
|
||||||
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены
|
`SceneSmoke` заменяет PlayMode-тест загрузки сцен: Single-загрузка сцены
|
||||||
ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены:
|
ломает стандартный PlayMode test runner. `SceneBuilder` пересобирает сцены:
|
||||||
|
|
|
||||||
|
|
@ -167,13 +167,15 @@ WebSocket-транспорта. Логика handshake, кадров и тайм
|
||||||
| 10 | `PassGuardIntent` | — |
|
| 10 | `PassGuardIntent` | — |
|
||||||
| 11 | `ResolveTriggerIntent` | `powerTarget`, `effectTarget` |
|
| 11 | `ResolveTriggerIntent` | `powerTarget`, `effectTarget` |
|
||||||
| 12 | `SelectTriggerIntent` | `index` |
|
| 12 | `SelectTriggerIntent` | `index` |
|
||||||
| 13 | `ResolveAbilityIntent` | `accept` |
|
| 13 | `ResolveAbilityIntent` | `accept`, `damageIndex?` |
|
||||||
| 14 | `ChooseIntent` | `index` |
|
| 14 | `ChooseIntent` | `index` |
|
||||||
| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?` |
|
| 15 | `ActivateAbilityIntent` | `instanceId`, `discardPrintingId?`, `damageIndex?` |
|
||||||
| 16 | `EndTurnIntent` | — |
|
| 16 | `EndTurnIntent` | — |
|
||||||
| 17 | `ConcedeIntent` | — |
|
| 17 | `ConcedeIntent` | — |
|
||||||
|
|
||||||
Индексы кругов: `-1` — Авангард, `0..2` — задний ряд, `3..4` — передний.
|
Индексы кругов: `-1` — Авангард, `0..2` — задний ряд, `3..4` — передний.
|
||||||
|
`damageIndex` — индекс карты урона в порядке отображения (сначала закрытые,
|
||||||
|
затем открытые); `-1` — авто.
|
||||||
`GuardIntent.discardPrintingId` заполняется только для идеальной защиты:
|
`GuardIntent.discardPrintingId` заполняется только для идеальной защиты:
|
||||||
без него Sentinel не отменяет атаку; с ним указывается карта того же клана.
|
без него Sentinel не отменяет атаку; с ним указывается карта того же клана.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,12 @@ public sealed record InterceptCommand(int Slot) : GameCommand;
|
||||||
public sealed record PassGuardCommand : GameCommand;
|
public sealed record PassGuardCommand : GameCommand;
|
||||||
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
|
public sealed record ResolveTriggerCommand(int PowerTarget, int EffectTarget) : GameCommand;
|
||||||
public sealed record SelectTriggerCommand(int Index) : GameCommand;
|
public sealed record SelectTriggerCommand(int Index) : GameCommand;
|
||||||
public sealed record ResolveAbilityCommand(bool Accept) : GameCommand;
|
public sealed record ResolveAbilityCommand(bool Accept, int DamageIndex = -1) : GameCommand;
|
||||||
public sealed record ChooseCommand(int Index) : GameCommand;
|
public sealed record ChooseCommand(int Index) : GameCommand;
|
||||||
public sealed record ActivateAbilityCommand(int InstanceId, string? DiscardPrintingId = null) : GameCommand;
|
public sealed record ActivateAbilityCommand(
|
||||||
|
int InstanceId,
|
||||||
|
string? DiscardPrintingId = null,
|
||||||
|
int DamageIndex = -1) : GameCommand;
|
||||||
public sealed record EndTurnCommand : GameCommand;
|
public sealed record EndTurnCommand : GameCommand;
|
||||||
|
|
||||||
public sealed record CommandResult(bool Accepted, string? Reason)
|
public sealed record CommandResult(bool Accepted, string? Reason)
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ public sealed class GameEngine
|
||||||
|
|
||||||
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
|
/// <summary>Глубина разрешения эффекта: пока > 0, новые триггеры только ставятся в очередь.</summary>
|
||||||
private int _resolvingDepth;
|
private int _resolvingDepth;
|
||||||
|
private int _counterBlastDisplayIndex = -1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сколько проверок делает атакующая карта. Эффект может изменить число;
|
/// Сколько проверок делает атакующая карта. Эффект может изменить число;
|
||||||
|
|
@ -331,7 +332,7 @@ public sealed class GameEngine
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private CommandResult Activate(int instanceId, string? discardPrintingId)
|
private CommandResult Activate(int instanceId, string? discardPrintingId, int damageIndex)
|
||||||
{
|
{
|
||||||
if (State.Phase != Phase.Main)
|
if (State.Phase != Phase.Main)
|
||||||
return CommandResult.Reject("Активируемые способности доступны только в Основной фазе.");
|
return CommandResult.Reject("Активируемые способности доступны только в Основной фазе.");
|
||||||
|
|
@ -433,7 +434,16 @@ public sealed class GameEngine
|
||||||
_events.Add(new UnitRetiredEvent(player, slot));
|
_events.Add(new UnitRetiredEvent(player, slot));
|
||||||
}
|
}
|
||||||
|
|
||||||
ability.Resolve(context);
|
_counterBlastDisplayIndex = damageIndex;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ability.Resolve(context);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_counterBlastDisplayIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
_events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true));
|
_events.Add(new AbilityResolvedEvent(player, unit.PrintingId, true));
|
||||||
|
|
||||||
return CommandResult.Ok();
|
return CommandResult.Ok();
|
||||||
|
|
@ -495,14 +505,23 @@ public sealed class GameEngine
|
||||||
return CommandResult.Ok();
|
return CommandResult.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
private CommandResult ResolvePendingAbility(bool accept)
|
private CommandResult ResolvePendingAbility(bool accept, int damageIndex)
|
||||||
{
|
{
|
||||||
var ability = State.PendingAbility;
|
var ability = State.PendingAbility;
|
||||||
if (ability is null || State.Step != Step.Ability)
|
if (ability is null || State.Step != Step.Ability)
|
||||||
return CommandResult.Reject("Сейчас нет способности, ожидающей решения.");
|
return CommandResult.Reject("Сейчас нет способности, ожидающей решения.");
|
||||||
|
|
||||||
State.PendingAbility = null;
|
State.PendingAbility = null;
|
||||||
ResolveAbility(ability, accept);
|
_counterBlastDisplayIndex = damageIndex;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ResolveAbility(ability, accept);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_counterBlastDisplayIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
return CommandResult.Ok();
|
return CommandResult.Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -664,10 +683,30 @@ public sealed class GameEngine
|
||||||
|
|
||||||
public void CounterBlast(int player, int amount)
|
public void CounterBlast(int player, int amount)
|
||||||
{
|
{
|
||||||
|
var damage = State.Players[player].Damage;
|
||||||
var flipped = 0;
|
var flipped = 0;
|
||||||
foreach (var card in State.Players[player].Damage)
|
|
||||||
|
// Клиент передаёт индекс в том же порядке, в котором рисует урон:
|
||||||
|
// сначала закрытые, затем открытые карты.
|
||||||
|
if (_counterBlastDisplayIndex >= 0)
|
||||||
{
|
{
|
||||||
if (flipped == amount)
|
var order = DamageDisplayOrder(player);
|
||||||
|
if (_counterBlastDisplayIndex < order.Count)
|
||||||
|
{
|
||||||
|
var chosen = order[_counterBlastDisplayIndex].Card;
|
||||||
|
if (!chosen.FaceUp)
|
||||||
|
{
|
||||||
|
chosen.FaceUp = true;
|
||||||
|
flipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_counterBlastDisplayIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var card in damage)
|
||||||
|
{
|
||||||
|
if (flipped >= amount)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
if (card.FaceUp)
|
if (card.FaceUp)
|
||||||
|
|
@ -1680,9 +1719,9 @@ public sealed class GameEngine
|
||||||
PassGuardCommand => PassGuard(),
|
PassGuardCommand => PassGuard(),
|
||||||
ResolveTriggerCommand c => ResolveTrigger(c),
|
ResolveTriggerCommand c => ResolveTrigger(c),
|
||||||
SelectTriggerCommand c => SelectTrigger(c.Index),
|
SelectTriggerCommand c => SelectTrigger(c.Index),
|
||||||
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept),
|
ResolveAbilityCommand c => ResolvePendingAbility(c.Accept, c.DamageIndex),
|
||||||
ChooseCommand c => Choose(c.Index),
|
ChooseCommand c => Choose(c.Index),
|
||||||
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId),
|
ActivateAbilityCommand c => Activate(c.InstanceId, c.DiscardPrintingId, c.DamageIndex),
|
||||||
EndTurnCommand => EndTurn(),
|
EndTurnCommand => EndTurn(),
|
||||||
_ => CommandResult.Reject("Неизвестная команда."),
|
_ => CommandResult.Reject("Неизвестная команда."),
|
||||||
};
|
};
|
||||||
|
|
@ -2387,23 +2426,22 @@ public sealed class GameEngine
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<(CardInstance Card, int Index)> DamageDisplayOrder(int player) =>
|
||||||
|
State.Players[player].Damage
|
||||||
|
.Select((card, index) => (Card: card, Index: index))
|
||||||
|
.OrderBy(item => item.Card.FaceUp)
|
||||||
|
.ThenBy(item => item.Index)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
private void AskDamageChoice(int player, string prompt, Action<int> onChosen)
|
private void AskDamageChoice(int player, string prompt, Action<int> onChosen)
|
||||||
{
|
{
|
||||||
var damage = State.Players[player].Damage;
|
var order = DamageDisplayOrder(player);
|
||||||
|
|
||||||
// Закрытый урон показываем первым, затем открытый: так клиент
|
|
||||||
// отрисовывает зону урона и может сопоставить клик с вариантом.
|
|
||||||
var order = damage
|
|
||||||
.Select((card, index) => (card, index))
|
|
||||||
.OrderBy(item => item.card.FaceUp)
|
|
||||||
.ThenBy(item => item.index)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var options = order
|
var options = order
|
||||||
.Select((item, displayIndex) => new ChoiceOption(
|
.Select((item, displayIndex) => new ChoiceOption(
|
||||||
displayIndex,
|
displayIndex,
|
||||||
item.card.FaceUp
|
item.Card.FaceUp
|
||||||
? State.Catalog.CardOf(item.card.PrintingId).NameEn
|
? State.Catalog.CardOf(item.Card.PrintingId).NameEn
|
||||||
: "Закрытая карта"))
|
: "Закрытая карта"))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|
@ -2413,7 +2451,7 @@ public sealed class GameEngine
|
||||||
Prompt = prompt,
|
Prompt = prompt,
|
||||||
Options = options,
|
Options = options,
|
||||||
Kind = ChoiceTargetKind.Damage,
|
Kind = ChoiceTargetKind.Damage,
|
||||||
Resolve = displayIndex => onChosen(order[displayIndex].index),
|
Resolve = displayIndex => onChosen(order[displayIndex].Index),
|
||||||
};
|
};
|
||||||
State.Step = Step.Choice;
|
State.Step = Step.Choice;
|
||||||
State.ActivePlayer = player;
|
State.ActivePlayer = player;
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ public static class CommandCodec
|
||||||
PassGuardCommand => "passGuard",
|
PassGuardCommand => "passGuard",
|
||||||
ResolveTriggerCommand c => $"resolveTrigger{Separator}{c.PowerTarget}{Separator}{c.EffectTarget}",
|
ResolveTriggerCommand c => $"resolveTrigger{Separator}{c.PowerTarget}{Separator}{c.EffectTarget}",
|
||||||
SelectTriggerCommand c => $"selectTrigger{Separator}{c.Index}",
|
SelectTriggerCommand c => $"selectTrigger{Separator}{c.Index}",
|
||||||
ResolveAbilityCommand c => $"resolveAbility{Separator}{(c.Accept ? 1 : 0)}",
|
ResolveAbilityCommand c => $"resolveAbility{Separator}{(c.Accept ? 1 : 0)}{Separator}{c.DamageIndex}",
|
||||||
ChooseCommand c => $"choose{Separator}{c.Index}",
|
ChooseCommand c => $"choose{Separator}{c.Index}",
|
||||||
ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}",
|
ActivateAbilityCommand c => $"activate{Separator}{c.InstanceId}{Separator}{c.DiscardPrintingId}{Separator}{c.DamageIndex}",
|
||||||
EndTurnCommand => "endTurn",
|
EndTurnCommand => "endTurn",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
|
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
|
||||||
};
|
};
|
||||||
|
|
@ -56,9 +56,14 @@ public static class CommandCodec
|
||||||
"passGuard" => new PassGuardCommand(),
|
"passGuard" => new PassGuardCommand(),
|
||||||
"resolveTrigger" => new ResolveTriggerCommand(ParseInt(parts[1]), ParseInt(parts[2])),
|
"resolveTrigger" => new ResolveTriggerCommand(ParseInt(parts[1]), ParseInt(parts[2])),
|
||||||
"selectTrigger" => new SelectTriggerCommand(ParseInt(parts[1])),
|
"selectTrigger" => new SelectTriggerCommand(ParseInt(parts[1])),
|
||||||
"resolveAbility" => new ResolveAbilityCommand(parts[1] == "1"),
|
"resolveAbility" => new ResolveAbilityCommand(
|
||||||
|
parts[1] == "1",
|
||||||
|
parts.Length > 2 ? ParseInt(parts[2]) : -1),
|
||||||
"choose" => new ChooseCommand(ParseInt(parts[1])),
|
"choose" => new ChooseCommand(ParseInt(parts[1])),
|
||||||
"activate" => new ActivateAbilityCommand(ParseInt(parts[1]), Nullable(parts[2])),
|
"activate" => new ActivateAbilityCommand(
|
||||||
|
ParseInt(parts[1]),
|
||||||
|
Nullable(parts[2]),
|
||||||
|
parts.Length > 3 ? ParseInt(parts[3]) : -1),
|
||||||
"endTurn" => new EndTurnCommand(),
|
"endTurn" => new EndTurnCommand(),
|
||||||
_ => throw new FormatException($"Неизвестная команда: {encoded}"),
|
_ => throw new FormatException($"Неизвестная команда: {encoded}"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,17 @@ public static class IntentMapping
|
||||||
EffectTarget = c.EffectTarget,
|
EffectTarget = c.EffectTarget,
|
||||||
},
|
},
|
||||||
SelectTriggerCommand c => new SelectTriggerIntent { Index = c.Index },
|
SelectTriggerCommand c => new SelectTriggerIntent { Index = c.Index },
|
||||||
ResolveAbilityCommand c => new ResolveAbilityIntent { Accept = c.Accept },
|
ResolveAbilityCommand c => new ResolveAbilityIntent
|
||||||
|
{
|
||||||
|
Accept = c.Accept,
|
||||||
|
DamageIndex = c.DamageIndex,
|
||||||
|
},
|
||||||
ChooseCommand c => new ChooseIntent { Index = c.Index },
|
ChooseCommand c => new ChooseIntent { Index = c.Index },
|
||||||
ActivateAbilityCommand c => new ActivateAbilityIntent
|
ActivateAbilityCommand c => new ActivateAbilityIntent
|
||||||
{
|
{
|
||||||
InstanceId = c.InstanceId,
|
InstanceId = c.InstanceId,
|
||||||
DiscardPrintingId = c.DiscardPrintingId,
|
DiscardPrintingId = c.DiscardPrintingId,
|
||||||
|
DamageIndex = c.DamageIndex,
|
||||||
},
|
},
|
||||||
EndTurnCommand => new EndTurnIntent(),
|
EndTurnCommand => new EndTurnIntent(),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
|
_ => throw new ArgumentOutOfRangeException(nameof(command), command, "Неизвестная команда."),
|
||||||
|
|
@ -66,9 +71,9 @@ public static class IntentMapping
|
||||||
PassGuardIntent => new PassGuardCommand(),
|
PassGuardIntent => new PassGuardCommand(),
|
||||||
ResolveTriggerIntent c => new ResolveTriggerCommand(c.PowerTarget, c.EffectTarget),
|
ResolveTriggerIntent c => new ResolveTriggerCommand(c.PowerTarget, c.EffectTarget),
|
||||||
SelectTriggerIntent c => new SelectTriggerCommand(c.Index),
|
SelectTriggerIntent c => new SelectTriggerCommand(c.Index),
|
||||||
ResolveAbilityIntent c => new ResolveAbilityCommand(c.Accept),
|
ResolveAbilityIntent c => new ResolveAbilityCommand(c.Accept, c.DamageIndex),
|
||||||
ChooseIntent c => new ChooseCommand(c.Index),
|
ChooseIntent c => new ChooseCommand(c.Index),
|
||||||
ActivateAbilityIntent c => new ActivateAbilityCommand(c.InstanceId, c.DiscardPrintingId),
|
ActivateAbilityIntent c => new ActivateAbilityCommand(c.InstanceId, c.DiscardPrintingId, c.DamageIndex),
|
||||||
EndTurnIntent => new EndTurnCommand(),
|
EndTurnIntent => new EndTurnCommand(),
|
||||||
ConcedeIntent => null,
|
ConcedeIntent => null,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Неизвестный интент."),
|
_ => throw new ArgumentOutOfRangeException(nameof(intent), intent, "Неизвестный интент."),
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,9 @@ public sealed class SelectTriggerIntent : IntentPayload
|
||||||
public sealed class ResolveAbilityIntent : IntentPayload
|
public sealed class ResolveAbilityIntent : IntentPayload
|
||||||
{
|
{
|
||||||
[Key(0)] public bool Accept { get; set; }
|
[Key(0)] public bool Accept { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто.</summary>
|
||||||
|
[Key(1)] public int DamageIndex { get; set; } = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
|
|
@ -132,6 +135,9 @@ public sealed class ActivateAbilityIntent : IntentPayload
|
||||||
{
|
{
|
||||||
[Key(0)] public int InstanceId { get; set; }
|
[Key(0)] public int InstanceId { get; set; }
|
||||||
[Key(1)] public string? DiscardPrintingId { get; set; }
|
[Key(1)] public string? DiscardPrintingId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Индекс карты в зоне урона для оплаты Counter Blast; -1 — авто.</summary>
|
||||||
|
[Key(2)] public int DamageIndex { get; set; } = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,23 @@ public class GameEngineTests
|
||||||
Assert.Contains(engine.Events, e => e is MulliganEvent { Player: 0, Count: 2 });
|
Assert.Contains(engine.Events, e => e is MulliganEvent { Player: 0, Count: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CounterBlast_Uses_Selected_Damage_Card()
|
||||||
|
{
|
||||||
|
var engine = InMainPhase();
|
||||||
|
var conroe = MakeCard(Conroe);
|
||||||
|
engine.State.Players[0].RearGuards[0] = conroe;
|
||||||
|
GiveDamage(engine, 0, 2);
|
||||||
|
|
||||||
|
var first = engine.State.Players[0].Damage[0];
|
||||||
|
var second = engine.State.Players[0].Damage[1];
|
||||||
|
|
||||||
|
Assert.True(engine.Execute(new ActivateAbilityCommand(conroe.Id, null, 1)).Accepted);
|
||||||
|
|
||||||
|
Assert.False(first.FaceUp);
|
||||||
|
Assert.True(second.FaceUp);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Called_RearGuard_Enters_Standing_And_Slot_Is_Busy()
|
public void Called_RearGuard_Enters_Standing_And_Slot_Is_Busy()
|
||||||
{
|
{
|
||||||
|
|
@ -205,6 +222,7 @@ public class GameEngineTests
|
||||||
Assert.NotNull(engine.State.PendingTrigger);
|
Assert.NotNull(engine.State.PendingTrigger);
|
||||||
|
|
||||||
var rearGuard = engine.State.Players[0].RearGuards[0]!;
|
var rearGuard = engine.State.Players[0].RearGuards[0]!;
|
||||||
|
rearGuard.Rested = true;
|
||||||
Assert.True(rearGuard.Rested);
|
Assert.True(rearGuard.Rested);
|
||||||
|
|
||||||
Assert.True(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted);
|
Assert.True(engine.Execute(new ResolveTriggerCommand(-1, 0)).Accepted);
|
||||||
|
|
@ -246,7 +264,7 @@ public class GameEngineTests
|
||||||
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
|
StackDeck(engine.State.Players[0], Gallatin, Gallatin);
|
||||||
GiveDamage(engine, 1, 2);
|
GiveDamage(engine, 1, 2);
|
||||||
GiveDamage(engine, 0, 1);
|
GiveDamage(engine, 0, 1);
|
||||||
StackDeck(engine.State.Players[1], Elaine);
|
engine.State.Players[1].Deck.Insert(0, MakeCard(Elaine));
|
||||||
|
|
||||||
var chosen = engine.State.Players[1].Damage[0];
|
var chosen = engine.State.Players[1].Damage[0];
|
||||||
|
|
||||||
|
|
@ -814,6 +832,7 @@ public class GameEngineTests
|
||||||
engine.Execute(new CallCommand(Lien, 0));
|
engine.Execute(new CallCommand(Lien, 0));
|
||||||
var lien = engine.State.Players[0].RearGuards[0]!;
|
var lien = engine.State.Players[0].RearGuards[0]!;
|
||||||
Give(engine, 0, Marron);
|
Give(engine, 0, Marron);
|
||||||
|
lien.Rested = true;
|
||||||
|
|
||||||
Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted);
|
Assert.False(engine.Execute(new ActivateAbilityCommand(lien.Id, Marron)).Accepted);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue