From 035da18584506e097b7d758abba8c223dceb84aa Mon Sep 17 00:00:00 2001 From: Michal Vrba Date: Thu, 3 Sep 2026 17:54:36 +0200 Subject: [PATCH] improvements --- Faraon.Client/Components/Lobby.razor | 44 +++- Faraon.Client/Components/PlayerSlot.razor | 40 +++- Faraon.Client/Pages/Home.razor | 240 ++++++++++++++++++--- Faraon.Client/Services/FxService.cs | 28 +++ Faraon.Client/wwwroot/css/app.css | 243 +++++++++++++++++++++- Faraon.Client/wwwroot/js/gameFx.js | 161 +++++++++++++- Faraon.Core/AI/FaraonAI.cs | 6 +- Faraon.Core/Engine/ActionValidator.cs | 42 +++- Faraon.Core/Engine/GameEngine.cs | 71 ++++++- Faraon.Core/Models/Card.cs | 1 + Faraon.Core/Models/GameEvents.cs | 1 + Faraon.Core/Models/GameState.cs | 3 + Faraon.Core/Models/RuleOptions.cs | 61 +++++- Faraon.Tests/GameEngineTests.cs | 117 +++++++++++ 14 files changed, 1006 insertions(+), 52 deletions(-) diff --git a/Faraon.Client/Components/Lobby.razor b/Faraon.Client/Components/Lobby.razor index 4c46c62..fec83c6 100644 --- a/Faraon.Client/Components/Lobby.razor +++ b/Faraon.Client/Components/Lobby.razor @@ -63,16 +63,40 @@
- +
+
+ ❤️ +
+
Červená sedma (Oživenie / Resurrect)
+
Spôsob vrátenia skončeného hráča späť do hry
+
+
+ +
+
+
+ 🍃 +
+
Zelený dolník (Rušenie sediem)
+
Zelený dolník zruší trest zo sedmy & pravidlá vykladania
+
+
+ +
+
+ Rýchlosť hry: + +
diff --git a/Faraon.Client/Components/PlayerSlot.razor b/Faraon.Client/Components/PlayerSlot.razor index 4ee4be7..bc50868 100644 --- a/Faraon.Client/Components/PlayerSlot.razor +++ b/Faraon.Client/Components/PlayerSlot.razor @@ -1,8 +1,15 @@ @namespace Faraon.Client.Components -
-
- @if (IsCurrentTurn && !Player.HasFinished && !Player.IsEliminated) +
+
0 ? "can-be-revived" : "")"> + @if (IsFrozen && !Player.HasFinished && !Player.IsEliminated) + { +
+ + ❄️ ZMRAZENÝ +
+ } + else if (IsCurrentTurn && !Player.HasFinished && !Player.IsEliminated) {
@@ -45,6 +52,29 @@
}
+ + @if (IsFrozen && !Player.HasFinished && !Player.IsEliminated) + { +
+ + + + + + + +
+ } + + @if (CanBeRevived && Player.HasFinished && ReviveTurnsRemaining > 0) + { +
+ ❤️ + + @(ReviveRoundsRemaining > 1 ? $"Oživenie: {ReviveRoundsRemaining} kolá" : (ReviveTurnsRemaining <= 1 ? "Oživenie: 1 ťah" : "Oživenie: 1 kolo")) + +
+ }
@@ -62,6 +92,10 @@ @code { [Parameter, EditorRequired] public Player Player { get; set; } = default!; [Parameter] public bool IsCurrentTurn { get; set; } = false; + [Parameter] public bool IsFrozen { get; set; } = false; + [Parameter] public bool CanBeRevived { get; set; } = false; + [Parameter] public int ReviveRoundsRemaining { get; set; } = 0; + [Parameter] public int ReviveTurnsRemaining { get; set; } = 0; [Parameter] public bool IsOlympicMode { get; set; } = false; [Parameter] public string PositionClass { get; set; } = "slot-top"; } diff --git a/Faraon.Client/Pages/Home.razor b/Faraon.Client/Pages/Home.razor index 9bd04c1..76bb119 100644 --- a/Faraon.Client/Pages/Home.razor +++ b/Faraon.Client/Pages/Home.razor @@ -31,6 +31,10 @@ else
+ +
} + + + @if (Engine.State.TopCard != null && Engine.State.TopCard.IsGreenUnderKnave && Engine.State.Rules.GreenUnderKnaveMode == GreenUnderKnaveMode.UniversalWildcard) + { +
+ 🍃 ZELENÝ DOLNÍK: Daj čokoľvek! +
+ }
-
-
0; + } +
+
- @if (IsViewingPlayerTurn && !ViewingPlayer.HasFinished && !ViewingPlayer.IsEliminated) + @if (isBottomFrozen && !ViewingPlayer.HasFinished && !ViewingPlayer.IsEliminated) + { +
+ + @(ViewingPlayer.Type == PlayerType.Human ? "❄️ STOJÍŠ TOTO KOLO" : "❄️ ZMRAZENÝ") +
+ } + else if (IsViewingPlayerActiveTurn && !ViewingPlayer.HasFinished && !ViewingPlayer.IsEliminated) {
- SI NA ŤAHU + @(ViewingPlayer.Type == PlayerType.Human ? "SI NA ŤAHU" : "NA ŤAHU")
} + + @if (ViewingPlayer.HasFinished) + { +
+ @ViewingPlayer.FinishedPosition +
+ } +
@ViewingPlayer.Avatar - @ViewingPlayer.Name + + @ViewingPlayer.Name + @if (ViewingPlayer.Type == PlayerType.AI) + { + (AI) + } + (@ViewingPlayer.Hand.Count @(ViewingPlayer.Hand.Count == 1 ? "karta" : (ViewingPlayer.Hand.Count < 5 ? "karty" : "kariet"))) @if (Engine.State.Rules.OlympicMode) { @@ -119,6 +170,29 @@ else }
+ + @if (isBottomFrozen && !ViewingPlayer.HasFinished && !ViewingPlayer.IsEliminated) + { +
+ + + + + + + +
+ } + + @if (canBottomBeRevived && ViewingPlayer.HasFinished) + { +
+ ❤️ + + @(Engine.State.ReviveRoundsRemaining > 1 ? $"Oživenie: {Engine.State.ReviveRoundsRemaining} kolá" : (Engine.State.ReviveTurnsRemaining <= 1 ? "Oživenie: 1 ťah" : "Oživenie: 1 kolo")) + +
+ }
@@ -232,17 +306,24 @@ else return Engine.State.CurrentPlayer; } - // Inak primárne prvého živého hráča v hre + // Inak primárne prvého živého človeka v hre var firstHuman = Engine.State.Players.FirstOrDefault(p => p.Type == PlayerType.Human); - return firstHuman ?? Engine.State.CurrentPlayer; + if (firstHuman != null) return firstHuman; + + // Ak sú všetci hráči AI, spodný hráč je PEVNE prvý hráč (Players[0]), aby sa stôl a rozmiestnenie nemenili + return Engine.State.Players[0]; } } - private bool IsViewingPlayerTurn => + private bool IsViewingPlayerActiveTurn => Engine != null && Engine.State.TurnState == TurnState.Playing && Engine.State.CurrentPlayer == ViewingPlayer; + private bool IsViewingPlayerTurn => + IsViewingPlayerActiveTurn && + ViewingPlayer.Type == PlayerType.Human; + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) @@ -269,6 +350,7 @@ else Engine.StartGame(gamePlayers, ConfiguredRules); _previousTurnPlayer = Engine.State.CurrentPlayer; + _ = Fx.SetSpeedLevelAsync(Engine.State.Rules.SpeedLevel); IsInLobby = false; LastEventMessage = "Hra začala! Veľa šťastia."; @@ -294,9 +376,29 @@ else await Fx.PlaySevenStackAsync(Math.Max(1, e.ExtraData / 3)); await Fx.TriggerDopamineBannerAsync($"🔥 +{e.ExtraData} KARIET!", "Trestná reťaz sediem", "triple"); break; + case GameEventType.SevenCanceled: + await Fx.PlayLeafShieldAsync(); + await Fx.TriggerDopamineBannerAsync("🍃 TREST ZRUŠENÝ!", $"{e.SourcePlayer?.Name ?? "Hráč"} zrušil trest Zeleným dolníkom!", "triple"); + break; case GameEventType.AceSkipped: + if (e.Message.Contains("stojí toto kolo")) + { + if (e.SourcePlayer != null) + { + await Fx.TriggerIceShatterAsync(GetPlayerPosition(e.SourcePlayer)); + } + } + else + { + await Fx.TriggerFreezeEffectAsync(); + } + break; case GameEventType.AceCountered: - await Fx.TriggerFreezeEffectAsync(); + if (e.SourcePlayer != null) + { + await Fx.TriggerIceShatterAsync(GetPlayerPosition(e.SourcePlayer)); + await Fx.TriggerDopamineBannerAsync("⚡ ROZMRAZENÝ!", $"{e.SourcePlayer.Name} prebil Eso vlastným Esom!", "revive"); + } break; case GameEventType.PlayerRevived: await Fx.TriggerReviveEffectAsync(e.TargetPlayer?.Name ?? e.SourcePlayer?.Name ?? "Hráč"); @@ -353,6 +455,44 @@ else private static readonly string[] AvailableDecks = new[] { "original", "janosik", "spongebob", "cyberpunk", "fantasy", "egypt", "pets", "pixel" }; + private int MultiCardDelayMs => Engine?.State.Rules.SpeedLevel switch + { + 1 => 130, + 2 => 105, + 4 => 50, + 5 => 25, + _ => 80 + }; + + private async Task CycleSpeed() + { + if (Engine == null) return; + int next = (Engine.State.Rules.SpeedLevel % 5) + 1; + Engine.State.Rules.SpeedLevel = next; + await Fx.SetSpeedLevelAsync(next); + StateHasChanged(); + } + + private string GetSpeedButtonText(int level) => level switch + { + 1 => "🐢 0.5x", + 2 => "🚶 0.75x", + 3 => "⚡ 1x", + 4 => "🔥 1.5x", + 5 => "🚀 2.5x", + _ => "⚡ 1x" + }; + + private string GetSpeedDescription(int level) => level switch + { + 1 => "0.5x (Pomalá)", + 2 => "0.75x (Pokojná)", + 3 => "1x (Normálna - stredná)", + 4 => "1.5x (Rýchla)", + 5 => "2.5x (Turbo / Blesková)", + _ => "1x (Normálna)" + }; + private void ToggleDeck() { if (Engine == null) return; @@ -393,10 +533,17 @@ else _ = Fx.TriggerComboSlamAsync(4, "👑 KVADRA / MEGA COMBO!"); } - foreach (var card in cards) + for (int i = 0; i < cards.Count; i++) { - _ = Fx.AnimateCardPlayAsync(pos, card, deck); - if (cards.Count > 1) await Task.Delay(90); + if (i == cards.Count - 1) + { + await Fx.AnimateCardPlayAsync(pos, cards[i], deck); + } + else + { + _ = Fx.AnimateCardPlayAsync(pos, cards[i], deck); + await Task.Delay(MultiCardDelayMs); + } } Engine.PlayCards(ViewingPlayer, cards); @@ -412,10 +559,19 @@ else if (Engine.State.PendingDrawCount > 0) { - for (int i = 0; i < Math.Min(Engine.State.PendingDrawCount, 4); i++) + int drawCount = Engine.State.PendingDrawCount; + int staggerDelay = Math.Max(25, (int)(MultiCardDelayMs * 0.75)); + for (int i = 0; i < drawCount; i++) { - _ = Fx.AnimateCardDrawAsync(pos, deck); - await Task.Delay(90); + if (i == drawCount - 1) + { + await Fx.AnimateCardDrawAsync(pos, deck); + } + else + { + _ = Fx.AnimateCardDrawAsync(pos, deck); + await Task.Delay(staggerDelay); + } } } else if (!Engine.State.PendingSkip) @@ -460,10 +616,17 @@ else { string pos = GetPlayerPosition(ViewingPlayer); string deck = Engine.State.Rules.SelectedDeck; - foreach (var c in cards) + for (int i = 0; i < cards.Count; i++) { - _ = Fx.AnimateCardPlayAsync(pos, c, deck); - await Task.Delay(75); + if (i == cards.Count - 1) + { + await Fx.AnimateCardPlayAsync(pos, cards[i], deck); + } + else + { + _ = Fx.AnimateCardPlayAsync(pos, cards[i], deck); + await Task.Delay(MultiCardDelayMs); + } } Engine.BurnCards(ViewingPlayer, cards); StateHasChanged(); @@ -523,20 +686,34 @@ else _ = Fx.TriggerComboSlamAsync(4, $"👑 {aiPlayer.Name}: KVADRA!"); } - foreach (var c in cardsToPlay) + for (int i = 0; i < cardsToPlay.Count; i++) { - _ = Fx.AnimateCardPlayAsync(pos, c, deck); - if (cardsToPlay.Count > 1) await Task.Delay(85); + if (i == cardsToPlay.Count - 1) + { + await Fx.AnimateCardPlayAsync(pos, cardsToPlay[i], deck); + } + else + { + _ = Fx.AnimateCardPlayAsync(pos, cardsToPlay[i], deck); + await Task.Delay(MultiCardDelayMs); + } } Engine.PlayCards(aiPlayer, cardsToPlay, decision.ChosenSuit); } } else if (decision.Type == AIDecisionType.BurnCards && decision.Cards != null) { - foreach (var c in decision.Cards) + for (int i = 0; i < decision.Cards.Count; i++) { - _ = Fx.AnimateCardPlayAsync(pos, c, deck); - await Task.Delay(75); + if (i == decision.Cards.Count - 1) + { + await Fx.AnimateCardPlayAsync(pos, decision.Cards[i], deck); + } + else + { + _ = Fx.AnimateCardPlayAsync(pos, decision.Cards[i], deck); + await Task.Delay(MultiCardDelayMs); + } } Engine.BurnCards(aiPlayer, decision.Cards); } @@ -544,10 +721,19 @@ else { if (Engine.State.PendingDrawCount > 0) { - for (int i = 0; i < Math.Min(Engine.State.PendingDrawCount, 4); i++) + int drawCount = Engine.State.PendingDrawCount; + int staggerDelay = Math.Max(25, (int)(MultiCardDelayMs * 0.75)); + for (int i = 0; i < drawCount; i++) { - _ = Fx.AnimateCardDrawAsync(pos, deck); - await Task.Delay(90); + if (i == drawCount - 1) + { + await Fx.AnimateCardDrawAsync(pos, deck); + } + else + { + _ = Fx.AnimateCardDrawAsync(pos, deck); + await Task.Delay(staggerDelay); + } } } else if (!Engine.State.PendingSkip) diff --git a/Faraon.Client/Services/FxService.cs b/Faraon.Client/Services/FxService.cs index 7b9b308..7d23a9d 100644 --- a/Faraon.Client/Services/FxService.cs +++ b/Faraon.Client/Services/FxService.cs @@ -48,11 +48,30 @@ public class FxService catch { } } + public async ValueTask TriggerIceShatterAsync(string position) + { + try + { + await _js.InvokeVoidAsync("FaraonFx.triggerIceShatter", position); + } + catch { } + } + public async ValueTask PlayHornikChimeAsync() { try { await _js.InvokeVoidAsync("FaraonFx.playHornikChime"); } catch { } } + public async ValueTask PlayLeafShieldAsync(string? position = null) + { + try + { + await _js.InvokeVoidAsync("FaraonFx.playLeafShield"); + await _js.InvokeVoidAsync("FaraonFx.triggerLeafParticles", null, null, 40); + } + catch { } + } + public async ValueTask PlayBurnExplosionAsync() { try @@ -123,6 +142,15 @@ public class FxService catch { } } + public async ValueTask SetSpeedLevelAsync(int level) + { + try + { + await _js.InvokeVoidAsync("FaraonFx.setSpeedLevel", level); + } + catch { } + } + public async ValueTask PlaySelectStepAsync(int step) { try { await _js.InvokeVoidAsync("FaraonFx.playSelectStep", step); } catch { } diff --git a/Faraon.Client/wwwroot/css/app.css b/Faraon.Client/wwwroot/css/app.css index bdb14f3..c859763 100644 --- a/Faraon.Client/wwwroot/css/app.css +++ b/Faraon.Client/wwwroot/css/app.css @@ -156,6 +156,26 @@ body, html { transform: scale(1.1); } +.btn-speed-control { + width: auto !important; + min-width: 60px; + padding: 0 10px; + border-radius: 20px !important; + font-size: 0.8rem !important; + font-weight: 800; + letter-spacing: 0.3px; + background: rgba(245, 176, 65, 0.15) !important; + border-color: rgba(245, 176, 65, 0.45) !important; + color: var(--gold) !important; + white-space: nowrap; +} + +.btn-speed-control:hover { + background: rgba(245, 176, 65, 0.35) !important; + border-color: var(--gold) !important; + box-shadow: 0 0 10px rgba(245, 176, 65, 0.4); +} + /* TABLE FELT & SLOTS */ .poker-table-surface { flex: 1; @@ -405,14 +425,229 @@ body, html { font-weight: 700; } +/* FROZEN / ICE STATUS STYLES */ +.avatar-badge.is-frozen, .avatar-badge.is-skipped { - border-color: var(--ice-blue); - box-shadow: 0 0 15px var(--ice-blue); + background: linear-gradient(135deg, rgba(0, 229, 255, 0.45) 0%, rgba(255, 255, 255, 0.25) 45%, rgba(0, 150, 255, 0.4) 100%) !important; + border-color: #00e5ff !important; + box-shadow: 0 0 26px rgba(0, 229, 255, 0.95), inset 0 0 16px rgba(255, 255, 255, 0.7) !important; + backdrop-filter: blur(8px) brightness(1.25) !important; + transform: scale(1.08); + overflow: visible; + animation: freeze-pulse 2s infinite ease-in-out alternate; +} + +@keyframes freeze-pulse { + 0% { + box-shadow: 0 0 18px rgba(0, 229, 255, 0.75), inset 0 0 12px rgba(255, 255, 255, 0.45); + border-color: #00e5ff; + } + 100% { + box-shadow: 0 0 35px rgba(0, 229, 255, 1), 0 0 55px rgba(0, 229, 255, 0.5), inset 0 0 20px rgba(255, 255, 255, 0.85); + border-color: #e0ffff; + } +} + +/* FROZEN ICE AURA (SLOT) */ +.player-slot.frozen-slot::before, +.slot-bottom.frozen-slot::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 150%; + height: 150%; + background: radial-gradient(ellipse at center, rgba(0, 229, 255, 0.38) 0%, rgba(0, 229, 255, 0.12) 50%, transparent 75%) !important; + pointer-events: none; + z-index: -1; + border-radius: 50%; + animation: ice-mist 2.4s infinite ease-in-out alternate !important; +} + +@keyframes ice-mist { + 0% { transform: translate(-50%, -50%) scale(0.92); opacity: 0.7; } + 100% { transform: translate(-50%, -50%) scale(1.18); opacity: 1; filter: drop-shadow(0 0 18px rgba(0, 229, 255, 0.6)); } +} + +/* FROZEN BADGE (ZMRAZENÝ) */ +.frozen-indicator-badge { + position: absolute; + top: -14px; + left: 50%; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 5px; + background: linear-gradient(135deg, #00e5ff, #80d8ff, #ffffff); + color: #003040; + font-size: 0.66rem; + font-weight: 900; + letter-spacing: 0.8px; + padding: 2px 9px; + border-radius: 12px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6), 0 0 18px rgba(0, 229, 255, 0.9); + border: 1.5px solid #ffffff; + white-space: nowrap; + z-index: 25; + animation: frozen-badge-float 2s ease-in-out infinite alternate; +} + +@keyframes frozen-badge-float { + 0% { transform: translateX(-50%) translateY(0); } + 100% { transform: translateX(-50%) translateY(-3px); } +} + +.frozen-indicator-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: #00b0ff; + box-shadow: 0 0 6px #00e5ff; + animation: dot-blink 0.8s ease-in-out infinite alternate; +} + +/* ICICLES HANGING UNDER FROZEN BADGE */ +.icicles-row { + position: absolute; + bottom: -10px; + left: 4px; + right: 4px; + height: 12px; + display: flex; + justify-content: space-around; + pointer-events: none; + z-index: 22; +} + +.icicle { + width: 0; + height: 0; + border-left: 3.5px solid transparent; + border-right: 3.5px solid transparent; + border-top: 9px solid rgba(224, 247, 250, 0.92); + filter: drop-shadow(0 2px 4px rgba(0, 229, 255, 0.85)); +} + +.icicle:nth-child(even) { + border-top-width: 13px; + border-left-width: 4.5px; + border-right-width: 4.5px; +} + +.icicle:nth-child(3n) { + border-top-width: 7px; + border-left-width: 2.5px; + border-right-width: 2.5px; +} + +/* DISCARD PILE ACE FREEZE BADGE */ +.ace-freeze-badge { + position: absolute; + top: -18px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #00e5ff, #0091ea, #00b0ff); + color: #ffffff; + font-weight: 900; + font-size: 0.82rem; + padding: 3px 12px; + border-radius: 20px; + box-shadow: 0 0 22px rgba(0, 229, 255, 0.95), 0 4px 12px rgba(0, 0, 0, 0.7); + border: 2px solid #ffffff; + white-space: nowrap; + z-index: 25; + animation: ace-freeze-pulse 1.6s infinite alternate; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); +} + +@keyframes ace-freeze-pulse { + 0% { transform: translateX(-50%) scale(0.95); box-shadow: 0 0 16px rgba(0, 229, 255, 0.75); } + 100% { transform: translateX(-50%) scale(1.06); box-shadow: 0 0 32px rgba(0, 229, 255, 1); } +} + +/* DISCARD PILE GREEN WILDCARD BADGE */ +.green-wildcard-badge { + position: absolute; + top: -18px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #1b4332, #2d6a4f, #52b788); + color: #ffffff; + font-weight: 900; + font-size: 0.82rem; + padding: 3px 12px; + border-radius: 20px; + box-shadow: 0 0 20px rgba(82, 183, 136, 0.9), 0 4px 12px rgba(0, 0, 0, 0.7); + border: 2px solid #b7e4c7; + white-space: nowrap; + z-index: 25; + animation: green-wildcard-pulse 1.6s infinite alternate; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); +} + +@keyframes green-wildcard-pulse { + 0% { transform: translateX(-50%) scale(0.95); box-shadow: 0 0 14px rgba(82, 183, 136, 0.7); } + 100% { transform: translateX(-50%) scale(1.06); box-shadow: 0 0 28px rgba(82, 183, 136, 1); } } .avatar-badge.is-finished { - opacity: 0.6; - filter: grayscale(0.5); + opacity: 0.65; + filter: grayscale(0.4); +} + +.avatar-badge.can-be-revived { + opacity: 0.95 !important; + filter: none !important; + border-color: #ff1744 !important; + box-shadow: 0 0 20px rgba(255, 23, 68, 0.7), inset 0 0 10px rgba(255, 23, 68, 0.3) !important; + animation: revive-target-pulse 1.4s infinite alternate; +} + +@keyframes revive-target-pulse { + 0% { box-shadow: 0 0 12px rgba(255, 23, 68, 0.5); } + 100% { box-shadow: 0 0 24px rgba(255, 23, 68, 0.95), 0 0 35px rgba(255, 82, 82, 0.4); } +} + +/* REVIVE COUNTDOWN BADGE */ +.revive-countdown-badge { + position: absolute; + bottom: -13px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #b71c1c, #d50000, #ff1744); + color: #ffffff; + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.5px; + padding: 2px 9px; + border-radius: 12px; + box-shadow: 0 0 14px rgba(255, 23, 68, 0.85), 0 2px 6px rgba(0, 0, 0, 0.6); + border: 1.5px solid #ff8a80; + white-space: nowrap; + z-index: 25; + display: inline-flex; + align-items: center; + gap: 4px; + animation: heartbeat-glow 1.2s infinite ease-in-out alternate; +} + +@keyframes heartbeat-glow { + 0% { transform: translateX(-50%) scale(0.96); box-shadow: 0 0 8px rgba(255, 23, 68, 0.6); } + 100% { transform: translateX(-50%) scale(1.05); box-shadow: 0 0 18px rgba(255, 23, 68, 1); } +} + +.heartbeat-icon { + display: inline-block; + animation: heart-beat 1s infinite; +} + +@keyframes heart-beat { + 0% { transform: scale(1); } + 15% { transform: scale(1.35); } + 30% { transform: scale(1); } + 45% { transform: scale(1.25); } + 60% { transform: scale(1); } } .badge-finished-pos { diff --git a/Faraon.Client/wwwroot/js/gameFx.js b/Faraon.Client/wwwroot/js/gameFx.js index 25c550f..6786523 100644 --- a/Faraon.Client/wwwroot/js/gameFx.js +++ b/Faraon.Client/wwwroot/js/gameFx.js @@ -3,11 +3,23 @@ window.FaraonFx = (function () { let audioCtx = null; let isMuted = false; + let speedMultiplier = 1.0; let particleCanvas = null; let particleCtx = null; let particles = []; let animationFrameId = null; + function setSpeedLevel(level) { + switch (level) { + case 1: speedMultiplier = 0.7; break; // 0.5x + case 2: speedMultiplier = 0.85; break; // 0.75x + case 4: speedMultiplier = 1.5; break; // 1.5x + case 5: speedMultiplier = 2.4; break; // 2.5x Turbo + case 3: + default: speedMultiplier = 1.0; break; + } + } + function getAudioContext() { if (!audioCtx) { const AudioContext = window.AudioContext || window.webkitAudioContext; @@ -168,6 +180,79 @@ window.FaraonFx = (function () { triggerScreenShake(3); } + function playIceCrack() { + if (isMuted) return; + const ctx = getAudioContext(); + if (!ctx) return; + + const now = ctx.currentTime; + + // Sharp ice snap noise + const bufferSize = ctx.sampleRate * 0.12; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i++) { + data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.02)); + } + + const noise = ctx.createBufferSource(); + noise.buffer = buffer; + + const filter = ctx.createBiquadFilter(); + filter.type = 'highpass'; + filter.frequency.setValueAtTime(3200, now); + + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0.45, now); + gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12); + + noise.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + noise.start(now); + + // Resonant high crystal ringing + [2400, 3600, 4800].forEach((freq, idx) => { + const osc = ctx.createOscillator(); + const oscGain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq + Math.random() * 200, now + idx * 0.02); + oscGain.gain.setValueAtTime(0.12, now + idx * 0.02); + oscGain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.02 + 0.22); + + osc.connect(oscGain); + oscGain.connect(ctx.destination); + osc.start(now + idx * 0.02); + osc.stop(now + idx * 0.02 + 0.23); + }); + + triggerScreenShake(4); + } + + function playLeafShield() { + if (isMuted) return; + const ctx = getAudioContext(); + if (!ctx) return; + + const now = ctx.currentTime; + [523.25, 659.25, 783.99, 1046.50].forEach((freq, idx) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(freq, now + idx * 0.05); + + gain.gain.setValueAtTime(0.2, now + idx * 0.05); + gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.05 + 0.35); + + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now + idx * 0.05); + osc.stop(now + idx * 0.05 + 0.36); + }); + + triggerScreenShake(2); + } + function playHornikChime() { if (isMuted) return; const ctx = getAudioContext(); @@ -386,7 +471,7 @@ window.FaraonFx = (function () { opacity: 1 } ], { - duration: 420, + duration: Math.round(420 / speedMultiplier), easing: 'cubic-bezier(0.175, 0.885, 0.32, 1.2)' }); @@ -429,7 +514,7 @@ window.FaraonFx = (function () { { transform: `translate(-50%, -50%) translate3d(${dx * 0.5}px, ${dy * 0.5 - 30}px, 0) scale(1.15) rotate(10deg)`, opacity: 1, offset: 0.5 }, { transform: `translate(-50%, -50%) translate3d(${dx}px, ${dy}px, 0) scale(0.6) rotate(0deg)`, opacity: 0.2 } ], { - duration: 380, + duration: Math.round(380 / speedMultiplier), easing: 'cubic-bezier(0.25, 1, 0.5, 1)' }); @@ -513,7 +598,7 @@ window.FaraonFx = (function () { const midX = (startPos.x + endPos.x) / 2; const midY = Math.min(startPos.y, endPos.y) - 35; - const duration = 380; + const duration = Math.round(380 / speedMultiplier); const startTime = performance.now(); function step(currentTime) { @@ -693,6 +778,71 @@ window.FaraonFx = (function () { } } + function triggerIceShatter(position) { + if (!particleCanvas) return; + let x = window.innerWidth / 2; + let y = window.innerHeight / 2; + + if (typeof position === 'string') { + const elem = getSlotElement(position); + const coords = elem ? getElementCenter(elem) : getPositionCoords(position); + x = coords.x; + y = coords.y; + } else if (position && position.x && position.y) { + x = position.x; + y = position.y; + } + + playIceCrack(); + + const colors = ['#00ffff', '#80d8ff', '#e0f7fa', '#ffffff', '#b2ebf2', '#87cefa']; + for (let i = 0; i < 40; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = 3 + Math.random() * 10; + particles.push({ + x: x + (Math.random() - 0.5) * 50, + y: y + (Math.random() - 0.5) * 40, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 2.5, + gravity: 0.22, + size: 6 + Math.random() * 9, + aspect: 0.35 + Math.random() * 1.5, + color: colors[Math.floor(Math.random() * colors.length)], + shape: Math.random() > 0.35 ? 'rect' : 'spark', + life: 1.1, + decay: 0.022 + Math.random() * 0.02, + rotation: Math.random() * Math.PI * 2, + vRot: (Math.random() - 0.5) * 0.4 + }); + } + } + + function triggerLeafParticles(x, y, count = 35) { + if (!particleCanvas) return; + const startX = x || window.innerWidth / 2; + const startY = y || window.innerHeight / 2; + const colors = ['#2d6a4f', '#52b788', '#74c69d', '#b7e4c7', '#d8f3dc', '#ffd700']; + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = 2.5 + Math.random() * 8; + particles.push({ + x: startX + (Math.random() - 0.5) * 30, + y: startY + (Math.random() - 0.5) * 30, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 1.5, + gravity: 0.12, + size: 5 + Math.random() * 7, + aspect: 0.4 + Math.random() * 1.5, + color: colors[Math.floor(Math.random() * colors.length)], + shape: 'spark', + life: 1.0, + decay: 0.025 + Math.random() * 0.02, + rotation: Math.random() * Math.PI * 2, + vRot: (Math.random() - 0.5) * 0.3 + }); + } + } + function triggerBurnExplosion(x, y) { if (!particleCanvas) return; const colors = ['#ff0000', '#ff4500', '#ffaa00', '#333333', '#ffffff']; @@ -1016,6 +1166,8 @@ window.FaraonFx = (function () { playCardDraw, playSevenStack, playAceFreeze, + playIceCrack, + playLeafShield, playHornikChime, playBurnExplosion, playVictory, @@ -1029,6 +1181,8 @@ window.FaraonFx = (function () { initParticles, triggerFireParticles, triggerIceParticles, + triggerIceShatter, + triggerLeafParticles, triggerBurnExplosion, triggerConfetti, triggerCoinJackpot, @@ -1037,6 +1191,7 @@ window.FaraonFx = (function () { animateCardPlay, animateCardDraw, animateTurnTransition, + setSpeedLevel, toggleMute }; })(); diff --git a/Faraon.Core/AI/FaraonAI.cs b/Faraon.Core/AI/FaraonAI.cs index fe2945f..275d626 100644 --- a/Faraon.Core/AI/FaraonAI.cs +++ b/Faraon.Core/AI/FaraonAI.cs @@ -124,11 +124,13 @@ public class FaraonAI : IFaraonAI private Card ChooseBestCardSmart(List playable, Player player, GameState state) { - // 1. Ak čelíme sedme, prioritne zahráme obyčajnú sedmu pred Červenou sedmou (ak je niekto vonku, šetríme ju) + // 1. Ak čelíme sedme, zahráme obyčajnú sedmu, Zeleného dolníka na zrušenie trestu, alebo Červenú sedmu if (state.PendingDrawCount > 0) { var regularSeven = playable.FirstOrDefault(c => c.IsSeven && !c.IsRedSeven); if (regularSeven != null) return regularSeven; + var greenUnderKnave = playable.FirstOrDefault(c => c.IsGreenUnderKnave); + if (greenUnderKnave != null) return greenUnderKnave; var redSeven = playable.FirstOrDefault(c => c.IsRedSeven); if (redSeven != null) return redSeven; } @@ -147,7 +149,7 @@ public class FaraonAI : IFaraonAI } // 4. Ak má Červenú sedmu a na stole je niekto na oživenie (LastFinishedPlayer), oživíme ho! - if (state.Rules.RedSevenRevives && state.LastFinishedPlayer != null) + if (state.Rules.RedSevenRevives && state.LastFinishedPlayer != null && state.ReviveTurnsRemaining > 0) { var redSeven = playable.FirstOrDefault(c => c.IsRedSeven); if (redSeven != null) return redSeven; diff --git a/Faraon.Core/Engine/ActionValidator.cs b/Faraon.Core/Engine/ActionValidator.cs index 611e0ae..4dd0eb2 100644 --- a/Faraon.Core/Engine/ActionValidator.cs +++ b/Faraon.Core/Engine/ActionValidator.cs @@ -12,9 +12,15 @@ public static class ActionValidator bool pendingSkip, RuleOptions rules) { - // 1. Ak je na stole trest zo sedmy (napr. +2, +4, +6) + // 1. Ak je na stole trest zo sedmy (napr. +3, +6, +9) if (pendingDrawCount > 0) { + // Zelený dolník ruší sedmičky + if (rules.GreenUnderKnaveCancelsSevens && card.IsGreenUnderKnave) + { + return true; + } + // Ak je povolené prebíjanie sediem, musí zahrať sedmu return rules.StackSevens && card.IsSeven; } @@ -38,6 +44,21 @@ public static class ActionValidator return true; } + // 4b. Červená sedma v režime AnyCardOneRound sa môže zahrať na ľubovoľnú kartu + if (card.IsRedSeven && rules.RedSevenMode == RedSevenMode.AnyCardOneRound) + { + return true; + } + + // 4c. Zelený dolník v režime UniversalWildcard ide na čokoľvek a naňho ide čokoľvek + if (rules.GreenUnderKnaveMode == GreenUnderKnaveMode.UniversalWildcard) + { + if (card.IsGreenUnderKnave || topCard.IsGreenUnderKnave) + { + return true; + } + } + // 5. Ak bola zmenená farba Horníkom if (activeSuit.HasValue) { @@ -66,6 +87,10 @@ public static class ActionValidator // 1. Ak je na stole trest zo sedmy if (pendingDrawCount > 0) { + if (rules.GreenUnderKnaveCancelsSevens && cards.Any(c => c.IsGreenUnderKnave)) + { + return true; + } return rules.StackSevens && cards.All(c => c.IsSeven); } @@ -87,6 +112,21 @@ public static class ActionValidator return true; } + // 4b. Combo so Srdcovou/Červenou sedmou v režime AnyCardOneRound sa dá vyložiť na čokoľvek + if (firstRank == Rank.Seven && cards.Any(c => c.IsRedSeven) && rules.RedSevenMode == RedSevenMode.AnyCardOneRound) + { + return true; + } + + // 4c. Zelený dolník v režime UniversalWildcard + if (rules.GreenUnderKnaveMode == GreenUnderKnaveMode.UniversalWildcard) + { + if (cards.Any(c => c.IsGreenUnderKnave) || topCard.IsGreenUnderKnave) + { + return true; + } + } + // 5. Ak bola zmenená farba Horníkom if (activeSuit.HasValue) { diff --git a/Faraon.Core/Engine/GameEngine.cs b/Faraon.Core/Engine/GameEngine.cs index d876e6a..c3941ad 100644 --- a/Faraon.Core/Engine/GameEngine.cs +++ b/Faraon.Core/Engine/GameEngine.cs @@ -31,6 +31,9 @@ public class GameEngine State.RoundLoser = null; State.RoundNumber = 1; State.LastFinishedPlayer = null; + State.ReviveTurnsRemaining = 0; + State.ReviveRoundsRemaining = 0; + State.ReviveWindowInitialTurns = 0; State.TurnCount = 1; State.PendingDrawCount = 0; State.PendingSkip = false; @@ -205,11 +208,13 @@ public class GameEngine State.PendingDrawCount += addedPenalty; var redSeven = cards.FirstOrDefault(c => c.IsRedSeven); - if (redSeven != null && State.Rules.RedSevenRevives && State.LastFinishedPlayer != null && State.LastFinishedPlayer != player) + if (redSeven != null && State.Rules.RedSevenRevives && State.LastFinishedPlayer != null && State.LastFinishedPlayer != player && State.ReviveTurnsRemaining > 0) { var revived = State.LastFinishedPlayer; revived.FinishedPosition = null; State.LastFinishedPlayer = null; + State.ReviveTurnsRemaining = 0; + State.ReviveRoundsRemaining = 0; DrawCardsForPlayer(revived, 3); EmitEvent(new GameEvent( @@ -257,6 +262,20 @@ public class GameEngine )); } } + // Efekt Zeleného dolníka (rušenie sediem) + else if (firstRank == Rank.UnderKnave && cards.Any(c => c.IsGreenUnderKnave) && State.Rules.GreenUnderKnaveCancelsSevens && State.PendingDrawCount > 0) + { + int canceled = State.PendingDrawCount; + State.PendingDrawCount = 0; + + EmitEvent(new GameEvent( + GameEventType.SevenCanceled, + player, + Cards: cards, + ExtraData: canceled, + Message: $"🍃 {player.Name} zahral Zeleného dolníka a ZRUŠIL trest {canceled} kariet!" + )); + } // Ostatné karty else { @@ -328,11 +347,13 @@ public class GameEngine State.PendingDrawCount += 3; // Červená sedma vracia do hry - if (card.IsRedSeven && State.Rules.RedSevenRevives && State.LastFinishedPlayer != null && State.LastFinishedPlayer != player) + if (card.IsRedSeven && State.Rules.RedSevenRevives && State.LastFinishedPlayer != null && State.LastFinishedPlayer != player && State.ReviveTurnsRemaining > 0) { var revived = State.LastFinishedPlayer; revived.FinishedPosition = null; State.LastFinishedPlayer = null; + State.ReviveTurnsRemaining = 0; + State.ReviveRoundsRemaining = 0; // Doberie 3 karty DrawCardsForPlayer(revived, 3); @@ -382,6 +403,21 @@ public class GameEngine State.PendingSkip = true; } + // Efekt Zeleného dolníka (rušenie sediem) + if (card.IsGreenUnderKnave && State.Rules.GreenUnderKnaveCancelsSevens && State.PendingDrawCount > 0) + { + int canceled = State.PendingDrawCount; + State.PendingDrawCount = 0; + + EmitEvent(new GameEvent( + GameEventType.SevenCanceled, + player, + Card: card, + ExtraData: canceled, + Message: $"🍃 {player.Name} zahral Zeleného dolníka a ZRUŠIL trest {canceled} kariet!" + )); + } + // Kontrola, či hráč vyhral / skončil CheckPlayerFinish(player); @@ -538,7 +574,22 @@ public class GameEngine { int nextPos = State.Players.Count(p => p.HasFinished) + 1; player.FinishedPosition = nextPos; - State.LastFinishedPlayer = player; + + if (State.Rules.RedSevenRevives) + { + State.LastFinishedPlayer = player; + int maxRounds = State.Rules.RedSevenMode == RedSevenMode.ColorOrSevenThreeRounds ? 3 : 1; + int activeCount = Math.Max(1, State.ActivePlayersCount); + State.ReviveRoundsRemaining = maxRounds; + State.ReviveTurnsRemaining = maxRounds * activeCount; + State.ReviveWindowInitialTurns = State.ReviveTurnsRemaining; + } + else + { + State.LastFinishedPlayer = null; + State.ReviveRoundsRemaining = 0; + State.ReviveTurnsRemaining = 0; + } EmitEvent(new GameEvent( GameEventType.PlayerFinished, @@ -682,6 +733,20 @@ public class GameEngine { if (State.IsGameOver || State.IsRoundOver) return; + // Odpočet lehoty na oživenie + if (State.LastFinishedPlayer != null && State.ReviveTurnsRemaining > 0) + { + State.ReviveTurnsRemaining--; + int activeCount = Math.Max(1, State.ActivePlayersCount); + State.ReviveRoundsRemaining = (int)Math.Ceiling((double)State.ReviveTurnsRemaining / activeCount); + + if (State.ReviveTurnsRemaining <= 0) + { + State.LastFinishedPlayer = null; + State.ReviveRoundsRemaining = 0; + } + } + int attempts = 0; do { diff --git a/Faraon.Core/Models/Card.cs b/Faraon.Core/Models/Card.cs index e939791..e9a3aa5 100644 --- a/Faraon.Core/Models/Card.cs +++ b/Faraon.Core/Models/Card.cs @@ -126,4 +126,5 @@ public record Card(Suit Suit, Rank Rank) public bool IsAce => Rank == Rank.Ace; public bool IsHornik => Rank == Rank.OverKnave; public bool IsRedSeven => Rank == Rank.Seven && Suit == Suit.Hearts; + public bool IsGreenUnderKnave => Rank == Rank.UnderKnave && Suit == Suit.Leaves; } diff --git a/Faraon.Core/Models/GameEvents.cs b/Faraon.Core/Models/GameEvents.cs index 98223e0..7f736d3 100644 --- a/Faraon.Core/Models/GameEvents.cs +++ b/Faraon.Core/Models/GameEvents.cs @@ -7,6 +7,7 @@ public enum GameEventType CardsBurned, CardDrawn, SevenStacked, + SevenCanceled, AceSkipped, AceCountered, SuitChanged, diff --git a/Faraon.Core/Models/GameState.cs b/Faraon.Core/Models/GameState.cs index 0443269..b755422 100644 --- a/Faraon.Core/Models/GameState.cs +++ b/Faraon.Core/Models/GameState.cs @@ -21,6 +21,9 @@ public class GameState public RuleOptions Rules { get; set; } = new(); public bool IsGameOver { get; set; } = false; public Player? LastFinishedPlayer { get; set; } + public int ReviveTurnsRemaining { get; set; } = 0; + public int ReviveRoundsRemaining { get; set; } = 0; + public int ReviveWindowInitialTurns { get; set; } = 0; public int TurnCount { get; set; } = 0; public TurnState TurnState { get; set; } = TurnState.Playing; public Player? PendingHornikPlayer { get; set; } diff --git a/Faraon.Core/Models/RuleOptions.cs b/Faraon.Core/Models/RuleOptions.cs index a974795..0287523 100644 --- a/Faraon.Core/Models/RuleOptions.cs +++ b/Faraon.Core/Models/RuleOptions.cs @@ -1,5 +1,19 @@ namespace Faraon.Core.Models; +public enum RedSevenMode +{ + Disabled, + AnyCardOneRound, + ColorOrSevenThreeRounds +} + +public enum GreenUnderKnaveMode +{ + Disabled, + OnlyOnSevenOrUnderKnave, // Ruší sedmy, bežné pravidlá (Zeleň / Dolník) + UniversalWildcard // Ruší sedmy, ide na čokoľvek a súper naňho môže dať čokoľvek +} + public class RuleOptions { /// @@ -18,9 +32,32 @@ public class RuleOptions public bool CounterAceWithAce { get; set; } = true; /// - /// Červená sedma vracia do hry hráča, ktorý sa už zbavil všetkých kariet (posledného víťaza). + /// Režim oživovania Červenou sedmou (Voľná do 1 kola, Klasická do 3 kôl, Vypnuté). /// - public bool RedSevenRevives { get; set; } = true; + public RedSevenMode RedSevenMode { get; set; } = RedSevenMode.ColorOrSevenThreeRounds; + + /// + /// Či je oživovanie Červenou sedmou povolené. + /// + public bool RedSevenRevives + { + get => RedSevenMode != RedSevenMode.Disabled; + set => RedSevenMode = value ? (RedSevenMode == RedSevenMode.Disabled ? RedSevenMode.ColorOrSevenThreeRounds : RedSevenMode) : RedSevenMode.Disabled; + } + + /// + /// Režim pre Zeleného dolníka (rušenie sediem a univerzálne vykladanie). + /// + public GreenUnderKnaveMode GreenUnderKnaveMode { get; set; } = GreenUnderKnaveMode.OnlyOnSevenOrUnderKnave; + + /// + /// Či Zelený dolník ruší trest zo sediem. + /// + public bool GreenUnderKnaveCancelsSevens + { + get => GreenUnderKnaveMode != GreenUnderKnaveMode.Disabled; + set => GreenUnderKnaveMode = value ? (GreenUnderKnaveMode == GreenUnderKnaveMode.Disabled ? GreenUnderKnaveMode.OnlyOnSevenOrUnderKnave : GreenUnderKnaveMode) : GreenUnderKnaveMode.Disabled; + } /// /// Zahranie 4 rovnakých kariet naraz ("Spálenie"). Spáli kôpku a hráč hrá znova. @@ -33,9 +70,25 @@ public class RuleOptions public bool PassAndPlayPrivacyScreen { get; set; } = false; /// - /// Rýchlosť AI ťahov v milisekundách (pre plynulé sledovanie animácií). + /// Úroveň rýchlosti hry (1 = 0.5x, 2 = 0.75x, 3 = 1x Normálna, 4 = 1.5x Rýchla, 5 = 2.5x Turbo). /// - public int AIDelayMs { get; set; } = 900; + public int SpeedLevel { get; set; } = 3; + + /// + /// Rýchlosť AI ťahov v milisekundách podľa zvolenej úrovne rýchlosti. + /// + public int AIDelayMs + { + get => SpeedLevel switch + { + 1 => 1600, + 2 => 1200, + 4 => 500, + 5 => 200, + _ => 900 + }; + set { } + } /// /// Zvolený vizuálny štýl balíčka ("original" alebo "spongebob"). diff --git a/Faraon.Tests/GameEngineTests.cs b/Faraon.Tests/GameEngineTests.cs index c68367b..2ef9be4 100644 --- a/Faraon.Tests/GameEngineTests.cs +++ b/Faraon.Tests/GameEngineTests.cs @@ -110,6 +110,8 @@ public class GameEngineTests p3.Hand.Clear(); p3.FinishedPosition = 1; engine.State.LastFinishedPlayer = p3; + engine.State.ReviveTurnsRemaining = 4; + engine.State.ReviveRoundsRemaining = 2; p1.Hand.Clear(); var redSeven = new Card(Suit.Hearts, Rank.Seven); @@ -126,6 +128,64 @@ public class GameEngineTests Assert.Equal(3, p3.Hand.Count); } + [Fact] + public void RedSeven_AnyCardOneRound_CanPlayOnAnyCard() + { + var rules = new RuleOptions { RedSevenMode = RedSevenMode.AnyCardOneRound }; + var redSeven = new Card(Suit.Hearts, Rank.Seven); + var topCard = new Card(Suit.Acorns, Rank.King); // completely different suit and rank + + var canPlay = ActionValidator.CanPlayCard(redSeven, topCard, null, 0, false, rules); + Assert.True(canPlay); + } + + [Fact] + public void RedSeven_ColorOrSeven_OnlyOnMatchingSuitOrSeven() + { + var rules = new RuleOptions { RedSevenMode = RedSevenMode.ColorOrSevenThreeRounds }; + var redSeven = new Card(Suit.Hearts, Rank.Seven); + var topAcornsKing = new Card(Suit.Acorns, Rank.King); + var topHeartsKing = new Card(Suit.Hearts, Rank.King); + var topBellsSeven = new Card(Suit.Bells, Rank.Seven); + + Assert.False(ActionValidator.CanPlayCard(redSeven, topAcornsKing, null, 0, false, rules)); + Assert.True(ActionValidator.CanPlayCard(redSeven, topHeartsKing, null, 0, false, rules)); + Assert.True(ActionValidator.CanPlayCard(redSeven, topBellsSeven, null, 0, false, rules)); + } + + [Fact] + public void RedSeven_ReviveExpiresAfterTurns() + { + var engine = new GameEngine(seed: 42); + var p1 = new Player("Hráč 1", PlayerType.Human); + var p2 = new Player("Hráč 2", PlayerType.Human); + var p3 = new Player("Hráč 3", PlayerType.Human); + var rules = new RuleOptions { RedSevenMode = RedSevenMode.AnyCardOneRound }; + engine.StartGame(new List { p1, p2, p3 }, rules); + + p3.Hand.Clear(); + p3.Hand.Add(new Card(Suit.Leaves, Rank.Eight)); + engine.State.DiscardPile.Clear(); + engine.State.DiscardPile.Add(new Card(Suit.Leaves, Rank.Seven)); + + // p3 finishes + engine.State.CurrentPlayerIndex = 2; + var played = engine.PlayCard(p3, p3.Hand[0]); + Assert.True(played); + Assert.True(p3.HasFinished); + Assert.Equal(p3, engine.State.LastFinishedPlayer); + Assert.True(engine.State.ReviveTurnsRemaining > 0); + + // Advance turns until window expires + while (engine.State.ReviveTurnsRemaining > 0) + { + engine.DrawOrAcknowledge(engine.State.CurrentPlayer); + } + + Assert.Null(engine.State.LastFinishedPlayer); + Assert.Equal(0, engine.State.ReviveTurnsRemaining); + } + [Fact] public void FourOfAKind_BurnsAndAllowsAnotherMove() { @@ -314,4 +374,61 @@ public class GameEngineTests Assert.Equal(p1, engine.State.MatchWinner); Assert.Equal(TurnState.GameOver, engine.State.TurnState); } + + [Fact] + public void GreenUnderKnave_CancelsSevenPenalty() + { + var engine = new GameEngine(seed: 42); + var p1 = new Player("Hráč 1", PlayerType.Human); + var p2 = new Player("Hráč 2", PlayerType.Human); + var rules = new RuleOptions { GreenUnderKnaveMode = GreenUnderKnaveMode.OnlyOnSevenOrUnderKnave }; + engine.StartGame(new List { p1, p2 }, rules); + + // p1 plays 7 -> penalty 3 + p1.Hand.Clear(); + p1.Hand.Add(new Card(Suit.Bells, Rank.Seven)); + p1.Hand.Add(new Card(Suit.Leaves, Rank.Eight)); + engine.State.DiscardPile.Clear(); + engine.State.DiscardPile.Add(new Card(Suit.Bells, Rank.King)); + + engine.PlayCard(p1, p1.Hand[0]); + Assert.Equal(3, engine.State.PendingDrawCount); + + // p2 plays Green Under Knave -> penalty becomes 0 + p2.Hand.Clear(); + var greenUnderKnave = new Card(Suit.Leaves, Rank.UnderKnave); + p2.Hand.Add(greenUnderKnave); + p2.Hand.Add(new Card(Suit.Hearts, Rank.Nine)); + + var played = engine.PlayCard(p2, greenUnderKnave); + Assert.True(played); + Assert.Equal(0, engine.State.PendingDrawCount); + Assert.Equal(greenUnderKnave, engine.State.TopCard); + } + + [Fact] + public void GreenUnderKnave_UniversalWildcard_AllowsAnyPlay() + { + var rules = new RuleOptions { GreenUnderKnaveMode = GreenUnderKnaveMode.UniversalWildcard }; + var greenUnderKnave = new Card(Suit.Leaves, Rank.UnderKnave); + var topAcornsKing = new Card(Suit.Acorns, Rank.King); + var arbitraryCard = new Card(Suit.Hearts, Rank.Ten); + + // Green Under Knave can be played on Acorns King + Assert.True(ActionValidator.CanPlayCard(greenUnderKnave, topAcornsKing, null, 0, false, rules)); + + // Arbitrary card (Hearts Ten) can be played on Green Under Knave + Assert.True(ActionValidator.CanPlayCard(arbitraryCard, greenUnderKnave, null, 0, false, rules)); + } + + [Fact] + public void GreenUnderKnave_Disabled_DoesNotCancelPenalty() + { + var rules = new RuleOptions { GreenUnderKnaveMode = GreenUnderKnaveMode.Disabled }; + var greenUnderKnave = new Card(Suit.Leaves, Rank.UnderKnave); + var topSeven = new Card(Suit.Hearts, Rank.Seven); + + // When facing penalty and disabled, Green Under Knave cannot be played + Assert.False(ActionValidator.CanPlayCard(greenUnderKnave, topSeven, null, 3, false, rules)); + } }