+
- @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));
+ }
}