using System.Diagnostics.CodeAnalysis; using System.Net; using System.Text; using Newtonsoft.Json; namespace TableturfBattleServer; public class Game(int maxPlayers) { [JsonIgnore] public Guid ID { get; } = Guid.NewGuid(); public GameState State { get; set; } public int TurnNumber { get; set; } public List Players { get; } = new(4); [JsonIgnore] internal Guid HostClientToken { get; set; } public int MaxPlayers { get; set; } = maxPlayers; [JsonProperty("stage")] public int? StageIndex { get; private set; } public Space[,]? Board { get; private set; } public Point[]? StartSpaces; public int? GoalWinCount { get; set; } public int? TurnTimeLimit { get; set; } public int? TurnTimeLeft { get; set; } [JsonIgnore] internal DateTime abandonedSince = DateTime.UtcNow; public bool AllowUpcomingCards { get; set; } = true; public bool AllowCustomCards { get; set; } public required StageSelectionRules StageSelectionRuleFirst { get; set; } public required StageSelectionRules StageSelectionRuleAfterWin { get; set; } public required StageSelectionRules StageSelectionRuleAfterDraw { get; set; } public bool ForceSameDeckAfterDraw { get; set; } public List StruckStages = []; [JsonIgnore] internal List deckCache = []; [JsonIgnore] internal List customCards = []; [JsonIgnore] internal List setStages = []; private static readonly PlayerColours[] Colours = [ new(new(0xf2200d), new(0xff8c1a), new(0xffd5cc), false), // Red new(new(0xf2740d), new(0xff4000), new(0xffcc99), true), // Orange new(new(0xecf901), new(0xfa9e00), new(0xf9f91f), true), // Yellow new(new(0xc0f915), new(0x6aff00), new(0xe6ff99), true), // LimeGreen new(new(0x06e006), new(0x33ffcc), new(0xb3ffd9), false), // Green new(new(0x00ffea), new(0x00a8e0), new(0x99ffff), true), // Turquoise new(new(0x4a5cfc), new(0x01edfe), new(0xd5e1e1), false), // Blue new(new(0xa106ef), new(0xff00ff), new(0xffb3ff), false), // Purple new(new(0xf906e0), new(0x8006f9), new(0xebb4fd), true), // Magenta ]; public bool TryAddPlayer(Player player, out int playerIndex, out Error error) { lock (this.Players) { if (this.State != GameState.WaitingForPlayers) { playerIndex = -1; error = new(HttpStatusCode.Gone, "GameAlreadyStarted", "The game has already started."); return false; } if (this.Players.Any(p => p.Token == player.Token)) { playerIndex = -1; error = new(HttpStatusCode.Conflict, "PlayerAlreadyJoined", "You're already in the game."); return false; } if (this.Players.Count >= this.MaxPlayers) { playerIndex = -1; error = new(HttpStatusCode.Conflict, "GameFull", "The game is full."); return false; } playerIndex = this.Players.Count; this.Players.Add(player); player.StageSelectionPrompt = this.StageSelectionRuleFirst.Method switch { StageSelectionMethod.Vote => new() { PromptType = StageSelectionPromptType.Vote, BannedStages = this.StageSelectionRuleFirst.BannedStages, StruckStages = Array.Empty() }, StageSelectionMethod.Strike => new() { PromptType = StageSelectionPromptType.VoteOrder, BannedStages = this.StageSelectionRuleFirst.BannedStages, StruckStages = Array.Empty() }, _ => new() { PromptType = StageSelectionPromptType.Wait, BannedStages = this.StageSelectionRuleFirst.BannedStages, StruckStages = Array.Empty() } }; error = default; return true; } } public bool GetPlayer(Guid clientToken, [MaybeNullWhen(false)] out int playerIndex, [MaybeNullWhen(false)] out Player player) { for (var i = 0; i < this.Players.Count; i++) { var player2 = this.Players[i]; if (player2.Token == clientToken) { playerIndex = i; player = player2; return true; } } playerIndex = -1; player = null; return false; } public bool TryChooseStages(Player player, ICollection stages, out Error error) { if (player.StageSelectionPrompt == null || player.StageSelectionPrompt.Value.PromptType == StageSelectionPromptType.Wait) { error = new(HttpStatusCode.Conflict, "CannotChooseStage", "You cannot choose stages now."); return false; } if (player.selectedStages != null) { error = new(HttpStatusCode.Conflict, "StageAlreadyChosen", "You've already chosen a stage."); return false; } if (player.StageSelectionPrompt.Value.PromptType == StageSelectionPromptType.VoteOrder) { if (stages.Count != 1) { error = new(HttpStatusCode.BadRequest, "InvalidStage", "Invalid stage selection."); return false; } } else { if (stages.Any(i => i >= StageDatabase.Stages.Count)) { error = new(HttpStatusCode.UnprocessableEntity, "InvalidStage", "Invalid stage selection."); return false; } var rule = this.GetCurrentStageSelectionRule(); if (stages.Intersect(rule.BannedStages).Any()) { error = new(HttpStatusCode.UnprocessableEntity, "IllegalStage", "A selected stage is banned."); return false; } if (player.StageSelectionPrompt.Value.StruckStages != null && stages.Intersect(player.StageSelectionPrompt.Value.StruckStages).Any()) { // Includes stages previously won on when counterpicking. error = new(HttpStatusCode.UnprocessableEntity, "IllegalStage", "A selected stage was struck."); return false; } } player.selectedStages = stages; error = default; return true; } public Deck GetDeck(string name, int sleeves, IEnumerable cardNumbers, IEnumerable cardUpgrades) { var deck = this.deckCache.FirstOrDefault(d => d.Name == name && d.Sleeves == sleeves && cardNumbers.SequenceEqual(from c in d.Cards select c.Number) && cardUpgrades.SequenceEqual(d.Upgrades)); if (deck == null) { deck = new(name, sleeves, (from i in cardNumbers select i <= ApiEndpoints.RECEIVED_CUSTOM_CARD_START ? customCards[ApiEndpoints.RECEIVED_CUSTOM_CARD_START - i] : CardDatabase.GetCard(i)).ToArray(), cardUpgrades.ToArray()); this.deckCache.Add(deck); } return deck; } public bool CanPlay(int playerIndex, Card card, int x, int y, int rotation, bool isSpecialAttack) { ArgumentNullException.ThrowIfNull(card); if (this.Board is null || this.Players[playerIndex].CurrentGameData is not SingleGameData gameData) return false; if (isSpecialAttack && (gameData.SpecialPoints < card.SpecialCost)) return false; var isAnchored = false; for (int dx = 0; dx < 8; dx++) { for (int dy = 0; dy < 8; dy++) { if (card.GetSpace(dx, dy, rotation) == Space.Empty) continue; var x2 = x + dx; var y2 = y + dy; if (x2 < 0 || x2 > this.Board.GetUpperBound(0) || y2 < 0 || y2 > this.Board.GetUpperBound(1)) return false; // Out of bounds. switch (this.Board[x2, y2]) { case Space.Wall: case Space.OutOfBounds: return false; case >= Space.SpecialInactive1: return false; // Can't overlap special spaces ever. case Space.Empty: break; default: if (!isSpecialAttack) return false; // Can't overlap ink except with a special attack. break; } if (!isAnchored) { // A normal play must be adjacent to ink of the player's colour. // A special attack must be adjacent to a special space of theirs. for (int dy2 = -1; dy2 <= 1; dy2++) { for (int dx2 = -1; dx2 <= 1; dx2++) { if (dx2 == 0 && dy2 == 0) continue; var x3 = x2 + dx2; var y3 = y2 + dy2; if (x3 < 0 || x3 > this.Board.GetUpperBound(0) || y3 < 0 || y3 > this.Board.GetUpperBound(1)) continue; if (this.Board[x3, y3] >= (isSpecialAttack ? Space.SpecialInactive1 : Space.Ink1) && (((int) this.Board[x3, y3]) & 3) == playerIndex) { isAnchored = true; break; } } } } } } return isAnchored; } private StageSelectionRules GetCurrentStageSelectionRule() { return this.Players.Count == 0 || this.Players[0].Games.Count <= 1 ? this.StageSelectionRuleFirst : this.Players.Any(p => p.Games[^2].won) ? this.StageSelectionRuleAfterWin : this.StageSelectionRuleAfterDraw; } internal void Tick() { if (this.State is GameState.WaitingForPlayers or GameState.ChoosingStage && this.Players.Count >= 2 && this.Players.All(p => p.StageSelectionPrompt == null || p.StageSelectionPrompt.Value.PromptType == StageSelectionPromptType.Wait || p.selectedStages != null)) { // Choose colours. var random = new Random(); if (this.State == GameState.WaitingForPlayers) { this.State = GameState.ChoosingStage; var index = random.Next(Colours.Length); var increment = this.Players.Count switch { 2 => random.Next(3, 7), 3 => random.Next(2, 4), _ => 2 }; for (int i = 0; i < this.Players.Count; i++) { var colours = Colours[index]; index = (index + increment) % Colours.Length; this.Players[i].Colour = colours.InkColour; this.Players[i].SpecialColour = colours.SpecialColour; this.Players[i].SpecialAccentColour = colours.SpecialAccentColour; this.Players[i].UIBaseColourIsSpecialColour = colours.UIBaseColourIsSpecialColour; } } // Choose the stage. var rule = this.GetCurrentStageSelectionRule(); switch (rule.Method) { case StageSelectionMethod.Vote: { var stageIndex = this.Players[random.Next(this.Players.Count)].selectedStages!.First(); if (stageIndex < 0) stageIndex = random.Next(StageDatabase.Stages.Count); this.LockInStage(stageIndex); this.SendEvent("stateChange", this, true); break; } case StageSelectionMethod.Random: { var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).ToList(); var stageIndex = legalStages[random.Next(legalStages.Count)]; this.LockInStage(stageIndex); this.SendEvent("stateChange", this, true); break; } case StageSelectionMethod.Counterpick: { var player = this.Players.FirstOrDefault(p => p.StageSelectionPrompt != null && p.StageSelectionPrompt?.PromptType != StageSelectionPromptType.Wait); if (player == null) { var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).ToList(); this.LockInStage(legalStages[random.Next(legalStages.Count)]); } else { if (player.selectedStages!.First() >= 0) this.LockInStage(player.selectedStages!.First()); else { var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).Except(player.StageSelectionPrompt!.Value.StruckStages).ToList(); this.LockInStage(legalStages[random.Next(legalStages.Count)]); } } this.SendEvent("stateChange", this, true); break; } case StageSelectionMethod.Strike: { var player = this.Players.FirstOrDefault(p => p.StageSelectionPrompt != null && p.StageSelectionPrompt?.PromptType != StageSelectionPromptType.Wait) ?? throw new InvalidOperationException("Couldn't find striking player?!"); switch (player.StageSelectionPrompt!.Value.PromptType) { case StageSelectionPromptType.VoteOrder: // Choose who will strike first. Player? firstPlayer = null; foreach (var player2 in this.Players) { if (player2.selectedStages!.First() == 0) { if (firstPlayer == null || random.Next(2) == 0) firstPlayer = player2; } } firstPlayer ??= this.Players[random.Next(this.Players.Count)]; // Present new prompts. foreach (var player2 in this.Players) { player2.StageSelectionPrompt = new() { PromptType = player2 == firstPlayer ? StageSelectionPromptType.Strike : StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, NumberOfStagesToStrike = 1 }; player2.selectedStages = null; } break; case StageSelectionPromptType.Strike: this.StruckStages.AddRange(player.selectedStages!); var index = this.Players.IndexOf(player); index = (index + 1) % this.Players.Count; // Present new prompts. for (var i = 0; i < this.Players.Count; i++) { this.Players[i].StageSelectionPrompt = i == index ? this.StruckStages.Count == 2 || Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).Except(this.StruckStages).Count() <= 3 ? new() { PromptType = StageSelectionPromptType.Choose, BannedStages = rule.BannedStages, StruckStages = this.StruckStages } : new() { PromptType = StageSelectionPromptType.Strike, BannedStages = rule.BannedStages, StruckStages = this.StruckStages, NumberOfStagesToStrike = 2 } : new() { PromptType = StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, StruckStages = this.StruckStages }; } break; case StageSelectionPromptType.Choose: if (player.selectedStages!.First() >= 0) this.LockInStage(player.selectedStages!.First()); else { var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).Except(player.StageSelectionPrompt!.Value.StruckStages).ToList(); this.LockInStage(legalStages[random.Next(legalStages.Count)]); } break; } this.SendEvent("stateChange", this, true); foreach (var player2 in this.Players) player2.selectedStages = null; break; } } } else if (this.State == GameState.ChoosingDeck && this.Players.All(p => p.CurrentGameData.Deck != null)) { this.StartGame(); this.SendEvent("stateChange", this, true); } else if (this.State == GameState.Redraw && this.Players.All(p => p.Move != null)) { var random = new Random(); foreach (var player in this.Players) { if (player.Move!.IsSpecialAttack) { player.Shuffle(random); } player.ClearMoves(); } this.State = GameState.Ongoing; this.TurnNumber = 1; this.TurnTimeLeft = this.TurnTimeLimit; this.SendEvent("stateChange", this, true); } else if (this.State == GameState.Ongoing && this.Players.All(p => p.Move != null)) { var moves = new Move?[this.Players.Count]; var placements = new List(); var anySpecialAttacks = false; var specialSpacesActivated = new List(); if (this.Board == null) throw new InvalidOperationException("No board?!"); foreach (var player in this.Players) { var move = player.Move!; player.CurrentGameData.turns.Add(new() { CardNumber = move.Card.Number, X = move.X, Y = move.Y, Rotation = move.Rotation, IsPass = move.IsPass, IsTimeout = move.IsTimeout, IsSpecialAttack = move.IsSpecialAttack }); } // Place the ink. (Placement placement, int cardSize)? placementData = null; var coveringMoves = 0; foreach (var i in Enumerable.Range(0, this.Players.Count).Where(i => this.Players[i] != null).OrderByDescending(i => this.Players[i]!.Move!.Card.Size)) { var player = this.Players[i]; var move = player.Move!; var isCovering = false; moves[i] = move; player.CardsUsed.Add(move.Card.Number); if (move.IsPass) { player.CurrentGameData.Passes++; player.CurrentGameData.SpecialPoints++; } else { if (move.IsSpecialAttack) { anySpecialAttacks = true; player.CurrentGameData.SpecialPoints -= move.Card.SpecialCost; } if (placementData == null || move.Card.Size != placementData.Value.cardSize) { if (placementData != null) placements.Add(placementData.Value.placement); placementData = (new(), move.Card.Size); } var placement = placementData.Value.placement; placement.Players.Add(i); for (int dy = 0; dy < 8; dy++) { var y = move.Y + dy; for (int dx = 0; dx < 8; dx++) { var x = move.X + dx; var point = new Point(x, y); switch (move.Card.GetSpace(dx, dy, move.Rotation)) { case Space.Ink1: if (placement.SpacesAffected.TryGetValue(point, out var space)) { if (space < Space.SpecialInactive1) { // Two ink spaces overlapped; create a wall there. this.Board[x, y] = placement.SpacesAffected[point] = Space.Wall; } } else { isCovering = this.Board[x, y] != Space.Empty; if (this.Board[x, y] < Space.SpecialInactive1) // Ink spaces can't overlap special spaces from larger cards. this.Board[x, y] = placement.SpacesAffected[point] = Space.Ink1 | (Space) i; } break; case Space.SpecialInactive1: if (placement.SpacesAffected.TryGetValue(point, out space) && space >= Space.SpecialInactive1) { // Two special spaces overlapped; create a wall there. this.Board[x, y] = placement.SpacesAffected[point] = Space.Wall; } else { // If a special space overlaps an ink space, overwrite it. isCovering = this.Board[x, y] != Space.Empty; this.Board[x, y] = placement.SpacesAffected[point] = Space.SpecialInactive1 | (Space) i; } break; } } } } if (isCovering) coveringMoves++; } if (placementData != null) placements.Add(placementData.Value.placement); // Activate special spaces. for (int y = 0; y < this.Board.GetLength(1); y++) { for (int x = 0; x < this.Board.GetLength(0); x++) { if ((this.Board[x, y] & Space.SpecialActive1) == Space.SpecialInactive1) { var anyEmptySpace = false; for (int dy = -1; !anyEmptySpace && dy <= 1; dy++) { for (int dx = -1; dx <= 1; dx++) { var x2 = x + dx; var y2 = y + dy; if (x2 >= 0 && x2 < this.Board.GetLength(0) && y2 >= 0 && y2 < this.Board.GetLength(1) && this.Board[x2, y2] == Space.Empty) { anyEmptySpace = true; break; } } } if (!anyEmptySpace) { var player = this.Players[(int) this.Board[x, y] & 3]!; this.Board[x, y] |= Space.SpecialActive1; player.CurrentGameData.SpecialPoints++; player.CurrentGameData.TotalSpecialPoints++; specialSpacesActivated.Add(new(x, y)); } } } } if (this.TurnNumber >= 12) { this.TurnTimeLeft = null; this.State = this.GoalWinCount is not null ? GameState.GameEnded : GameState.SetEnded; // Determine the winner and check whether a player has won the set yet. var scores = new int[this.Players.Count]; int? winner = null; int maxScore = 0; for (var x = 0; x < this.Board.GetLength(0); x++) { for (var y = 0; y < this.Board.GetLength(1); y++) { if (((int) this.Board[x, y] & 0xC) != 0) scores[(int) this.Board[x, y] & 0x3]++; } } for (var i = 0; i < scores.Length; i++) { if (scores[i] > maxScore) { winner = i; maxScore = scores[i]; } else if (scores[i] == maxScore) winner = null; } if (winner is not null) { this.Players[winner.Value].CurrentGameData.won = true; this.Players[winner.Value].GamesWon++; if (this.Players[winner.Value].GamesWon >= this.GoalWinCount) this.State = GameState.SetEnded; } foreach (var player in this.Players) player.ClearMoves(); this.SendEvent("gameEnd", new { game = this, moves, placements, specialSpacesActivated }, true); } else { this.TurnNumber++; this.TurnTimeLeft = this.TurnTimeLimit + (anySpecialAttacks ? 6 : 4) + coveringMoves + (specialSpacesActivated.Count > 0 ? 1 : 0); // Extra seconds for the animations. // Draw cards. foreach (var player in this.Players) { var index = player.GetHandIndex(player.Move!.Card.Number); var draw = player.CurrentGameData.drawOrder![this.TurnNumber + 2]; player.Hand![index] = player.CurrentGameData.Deck!.Cards[draw]; player.ClearMoves(); } this.SendEvent("turn", new { game = this, moves, placements, specialSpacesActivated }, true); } } else if (this.TurnTimeLeft != null) { --this.TurnTimeLeft; if (this.TurnTimeLeft <= -3 || (this.TurnTimeLeft <= 0 && this.Players.All(p => p.IsReady || !p.IsOnline))) { // Add a small grace period to account for network lag for online players. for (var i = 0; i < this.Players.Count; i++) { var player = this.Players[i]; if (player.Move == null) { if (this.State == GameState.Redraw) { // If someone times out during the redraw state, don't redraw. player.Move = new(player.Hand![0], false, 0, 0, 0, false, true); this.SendPlayerReadyEvent(i, true); } else if (this.State == GameState.Ongoing) { if (player.ProvisionalMove != null) player.Move = player.ProvisionalMove; else { // If someone times out during a game and didn't have a valid move highlighted, they'll automatically discard their largest card. player.Move = new(player.Hand!.MaxBy(c => c.Size)!, true, 0, 0, 0, false, true); } this.SendPlayerReadyEvent(i, true); } } } } } else if (this.State is GameState.GameEnded or GameState.SetEnded && this.Players.All(p => p.Move != null)) { this.SetupNextGame(); } } private void LockInStage(int stageIndex) { var stage = StageDatabase.Stages[stageIndex]; this.StageIndex = stageIndex; this.setStages.Add(stageIndex); this.Board = (Space[,]) stage.Grid.Clone(); // Place starting positions. var list = stage.StartSpaces.Where(s => s.Length >= this.Players.Count).MinBy(s => s.Length) ?? throw new InvalidOperationException("Couldn't find start spaces"); this.StartSpaces = list; for (int i = 0; i < this.Players.Count; i++) this.Board[list[i].X, list[i].Y] = Space.SpecialInactive1 | (Space) i; foreach (var player in this.Players) { player.StageSelectionPrompt = null; player.selectedStages = null; } if (this.ForceSameDeckAfterDraw && this.Players[0].Games.Count > 1 && !this.Players.Any(p => p.WonLastGame)) { foreach (var player in this.Players) player.CurrentGameData.Deck = player.Games[^2].Deck; this.StartGame(); } else this.State = GameState.ChoosingDeck; } private void StartGame() { // Draw cards. var random = new Random(); foreach (var player in this.Players) player.Shuffle(random); this.State = GameState.Redraw; this.TurnTimeLeft = this.TurnTimeLimit; } private void SetupNextGame() { this.State = GameState.ChoosingStage; this.StruckStages.Clear(); this.TurnTimeLeft = this.TurnTimeLimit; var winner = this.Players.FirstOrDefault(p => p.CurrentGameData.won); foreach (var player in this.Players) { player.selectedStages = null; player.Hand = null; player.CardsUsed.Clear(); player.ClearMoves(); player.Games.Add(new()); } var rule = this.GetCurrentStageSelectionRule(); var legalStages = Enumerable.Range(0, StageDatabase.Stages.Count).Except(rule.BannedStages).ToList(); switch (rule.Method) { case StageSelectionMethod.Same: this.LockInStage(this.setStages[^1]); break; case StageSelectionMethod.Vote: { if (legalStages.Count == 1) { this.LockInStage(legalStages[0]); break; } var bannedStages = legalStages.Count == 0 ? [] : rule.BannedStages; foreach (var player in this.Players) { player.StageSelectionPrompt = new() { PromptType = StageSelectionPromptType.Vote, BannedStages = bannedStages, StruckStages = [] }; } break; } case StageSelectionMethod.Random: { var random = new Random(); var stage = legalStages.Count switch { 0 => random.Next(StageDatabase.Stages.Count), 1 => legalStages[0], _ => legalStages[random.Next(legalStages.Count)] }; this.LockInStage(stage); break; } case StageSelectionMethod.Counterpick: { var playerPicking = this.Players.FirstOrDefault(p => p != winner) ?? this.Players[0]; // Should never reach the latter case. // Prevent picking stages that the player has previously won on if that would leave any legal stages. var struckStages = new HashSet(); for (var i = 0; i < playerPicking.Games.Count; i++) { if (playerPicking.Games[i].won && !rule.BannedStages.Contains(this.setStages[i])) struckStages.Add(this.setStages[i]); } foreach (var player in this.Players) player.StageSelectionPrompt = new() { PromptType = player == playerPicking ? StageSelectionPromptType.Choose : StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, StruckStages = struckStages }; break; } case StageSelectionMethod.Strike: { if (winner == null) { foreach (var player in this.Players) player.StageSelectionPrompt = new() { PromptType = StageSelectionPromptType.VoteOrder, BannedStages = rule.BannedStages, StruckStages = Array.Empty() }; } else { // After a win, the winner strikes first. foreach (var player in this.Players) player.StageSelectionPrompt = new() { PromptType = player == winner ? StageSelectionPromptType.Strike : StageSelectionPromptType.Wait, BannedStages = rule.BannedStages, StruckStages = Array.Empty(), NumberOfStagesToStrike = 2 }; } break; } } this.SendEvent("stateChange", this, true); } internal void SendPlayerReadyEvent(int playerIndex, bool isTimeout) => this.SendEvent("playerReady", new { playerIndex, isTimeout }, false); internal void AddConnection(int playerIndex, TableturfWebSocketBehaviour connection) { var player = this.Players[playerIndex]; player.AddConnection(connection); if (!player.IsOnline) { player.DisconnectedAt = null; this.SendEvent("playerOnline", new { playerIndex, player.IsOnline }, false); } } internal void RemoveConnection(Player player, TableturfWebSocketBehaviour connection) { var playerIndex = this.Players.IndexOf(player); player.RemoveConnection(connection); if (player.IsOnline && player.Connections.Count == 0) { if (this.State == GameState.WaitingForPlayers) { this.Players.RemoveAt(playerIndex); this.SendEvent("leave", new { playerIndex }, false); } else { player.DisconnectedAt = DateTime.UtcNow; this.SendEvent("playerOnline", new { playerIndex, player.IsOnline }, false); } } } internal void SendEvent(string eventType, T data, bool includePlayerData) { foreach (var session in Program.httpServer!.WebSocketServices.Hosts.First().Sessions.Sessions) { if (session is TableturfWebSocketBehaviour behaviour && behaviour.GameID == this.ID) { if (includePlayerData) { DTO.PlayerData? playerData = null; for (int i = 0; i < this.Players.Count; i++) { var player = this.Players[i]; if (player.Token == behaviour.ClientToken) { playerData = new(i, player); break; } } behaviour.SendInternal(JsonUtils.Serialise(new DTO.WebSocketPayloadWithPlayerData(eventType, data, playerData, behaviour.ClientToken == this.HostClientToken))); } else { behaviour.SendInternal(JsonUtils.Serialise(new DTO.WebSocketPayload(eventType, data))); } } } } public void WriteReplayData(Stream stream) { const int VERSION = 4; if (this.State < GameState.SetEnded) throw new InvalidOperationException("Can't save a replay until the set has ended."); using var writer = new BinaryWriter(stream, Encoding.UTF8, true); writer.Write((byte) VERSION); // Players writer.Write((byte) (this.Players.Count | (this.GoalWinCount ?? 0) << 4)); foreach (var player in this.Players) { writer.Write((byte) player.Colour.R); writer.Write((byte) player.Colour.G); writer.Write((byte) player.Colour.B); writer.Write((byte) player.SpecialColour.R); writer.Write((byte) player.SpecialColour.G); writer.Write((byte) player.SpecialColour.B); writer.Write((byte) player.SpecialAccentColour.R); writer.Write((byte) player.SpecialAccentColour.G); writer.Write((byte) player.SpecialAccentColour.B); var nameBytes = Encoding.UTF8.GetBytes(player.Name); writer.Write((byte) (nameBytes.Length | (player.UIBaseColourIsSpecialColour ? 0x80 : 0))); writer.Write(nameBytes); } // Custom cards writer.Write7BitEncodedInt(this.customCards.Count); foreach (var card in this.customCards) { writer.Write(card.Line1 ?? card.Name); writer.Write(card.Line2 ?? ""); writer.Write((byte) card.Rarity); writer.Write((byte) card.SpecialCost); writer.Write((byte) card.InkColour1.GetValueOrDefault().R); writer.Write((byte) card.InkColour1.GetValueOrDefault().G); writer.Write((byte) card.InkColour1.GetValueOrDefault().B); writer.Write((byte) card.InkColour2.GetValueOrDefault().R); writer.Write((byte) card.InkColour2.GetValueOrDefault().G); writer.Write((byte) card.InkColour2.GetValueOrDefault().B); for (var x = 0; x < 8; x++) { for (var y = 0; y < 8; y += 4) writer.Write((byte) ((int) card.GetSpace(x, y, 0) >> 2 | (int) card.GetSpace(x, y + 1, 0) | (int) card.GetSpace(x, y + 2, 0) << 2 | (int) card.GetSpace(x, y + 3, 0) << 4)); } } // Deck cache writer.Write7BitEncodedInt(this.deckCache.Count); foreach (var deck in this.deckCache) { writer.Write(deck.Name); writer.Write((byte) deck.Sleeves); foreach (var card in deck.Cards) writer.Write((short) card.Number); int upgradesPacked = 0; for (var i = 0; i < 15; i++) upgradesPacked |= deck.Upgrades[i] << (i * 2); writer.Write(upgradesPacked); } // Games for (int i = 0; i < this.Players[0].Games.Count; i++) { var stageNumber = this.setStages[i]; writer.Write((byte) stageNumber); foreach (var player in this.Players) { var gameData = player.Games[i]; writer.Write7BitEncodedInt(this.deckCache.IndexOf(gameData.Deck!)); for (int j = 0; j < 4; j += 2) writer.Write((byte) (gameData.initialDrawOrder![j] | gameData.initialDrawOrder[j + 1] << 4)); for (int j = 0; j < 15; j += 2) writer.Write((byte) (gameData.drawOrder![j] | (j < 14 ? gameData.drawOrder[j + 1] << 4 : gameData.won ? 0x80 : 0))); } for (int j = 0; j < 12; j++) { foreach (var player in this.Players) { var move = player.Games[i].turns[j]; writer.Write((short) move.CardNumber); writer.Write((byte) ((move.Rotation & 3) | (move.IsPass ? 0x80 : 0) | (move.IsSpecialAttack ? 0x40 : 0) | (move.IsTimeout ? 0x20 : 0))); writer.Write((sbyte) move.X); writer.Write((sbyte) move.Y); } } } } }