From 49787db1aa4b4e022343d8a302f470b8559f664b Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Sat, 18 Dec 2021 15:08:43 +0100 Subject: [PATCH] First working version --- Public/elements.js | 108 +++++++--- Readme.md | 2 + Sources/App/Extensions/Array+Extensions.swift | 53 +++++ Sources/App/Infos/PlayerInfo.swift | 17 +- Sources/App/Infos/TableInfo.swift | 31 ++- Sources/App/Management/TableManagement.swift | 16 +- Sources/App/Model/Card/Card+Array.swift | 8 + Sources/App/Model/Card/Card.swift | 16 ++ Sources/App/Model/GameClass.swift | 88 ++++++-- Sources/App/Model/GameConvertible.swift | 6 + Sources/App/Model/GameType.swift | 12 +- Sources/App/Model/OldPlayer.swift | 1 + Sources/App/Model/OldTable.swift | 2 +- Sources/App/Model/PlayerState.swift | 23 +++ .../App/Model/Players/AbstractPlayer.swift | 26 --- Sources/App/Model/Players/BiddingPlayer.swift | 70 ++++--- Sources/App/Model/Players/DealingPlayer.swift | 32 ++- .../App/Model/Players/FinishedPlayer.swift | 21 ++ Sources/App/Model/Players/Player.swift | 73 ++++++- Sources/App/Model/Players/PlayingPlayer.swift | 137 +++++++++++-- Sources/App/Model/Players/WaitingPlayer.swift | 18 +- Sources/App/Model/Players/WeddingPlayer.swift | 89 +++++++++ Sources/App/Model/Tables/AbstractTable.swift | 108 +++++++++- Sources/App/Model/Tables/BiddingTable.swift | 188 +++++++++++++++--- Sources/App/Model/Tables/DealingTable.swift | 84 +++++--- Sources/App/Model/Tables/FinishedTable.swift | 83 ++++++++ .../App/Model/Tables/ManageableTable.swift | 32 +++ Sources/App/Model/Tables/PlayingTable.swift | 166 +++++++++++++--- Sources/App/Model/Tables/Table.swift | 121 ----------- Sources/App/Model/Tables/WaitingTable.swift | 76 ++++--- Sources/App/Model/Tables/WeddingTable.swift | 121 +++++++++++ Sources/App/configure.swift | 3 +- 32 files changed, 1416 insertions(+), 415 deletions(-) create mode 100644 Sources/App/Model/GameConvertible.swift create mode 100644 Sources/App/Model/PlayerState.swift delete mode 100644 Sources/App/Model/Players/AbstractPlayer.swift create mode 100644 Sources/App/Model/Players/FinishedPlayer.swift create mode 100644 Sources/App/Model/Players/WeddingPlayer.swift create mode 100644 Sources/App/Model/Tables/FinishedTable.swift create mode 100644 Sources/App/Model/Tables/ManageableTable.swift delete mode 100644 Sources/App/Model/Tables/Table.swift create mode 100644 Sources/App/Model/Tables/WeddingTable.swift diff --git a/Public/elements.js b/Public/elements.js index e57de05..6e73e81 100644 --- a/Public/elements.js +++ b/Public/elements.js @@ -6,6 +6,11 @@ var playerName = "" var debugSessionToken = null const debugMode = true // Does not load session token, to allow multiple players per browser +const playerCardsElement = "player-cards" + +const offlineText = "Offline" +const missingPlayerText = "Leer" + function showDebugLogins() { document.getElementById("login-window-inner").innerHTML += "" + @@ -35,7 +40,7 @@ function showLoginElements() { hide("table-list") hide("game-bar") hide("table-players") - hide("player-cards") + hide(playerCardsElement) } function showTableListElements() { @@ -45,7 +50,7 @@ function showTableListElements() { setDisplayStyle("table-list", "inherit") hide("game-bar") hide("table-players") - hide("player-cards") + hide(playerCardsElement) } function showGameElements() { @@ -55,7 +60,7 @@ function showGameElements() { hide("table-list") setDisplayStyle("game-bar", "grid") setDisplayStyle("table-players", "grid") - setDisplayStyle("player-cards", "grid") + setDisplayStyle(playerCardsElement, "grid") } function showTableName(name) { @@ -63,11 +68,11 @@ function showTableName(name) { } function showConnectedState() { - showConnectionState("bottom", true) + showPlayerState("bottom", "") } function showDisconnectedState() { - showConnectionState("bottom", false) + showPlayerDisconnected("bottom") } function showDealButton() { @@ -138,26 +143,36 @@ function setTableListContent(content) { document.getElementById("table-list").innerHTML = content } -function setTablePlayerInfo(position, name, connected, active, card, layer) { - nameColor = active ? "var(--button-color)" : "var(--text-color)" - setTablePlayerElements(position, name, nameColor, connected, card, layer) -} - function setEmptyPlayerInfo(position) { - setTablePlayerElements(position, "Empty", "var(--secondary-text-color)", true, "", 1) + setTablePlayerName(position, null, false) + showPlayerState(position, "") + setTableCard(position, "", 1) } -function setTablePlayerElements(position, name, nameColor, connected, card, layer) { +function setTablePlayerName(position, name, active) { const nameElement = document.getElementById("table-player-name-" + position) - nameElement.style.color = nameColor - nameElement.innerHTML = name - showConnectionState(position, connected) - setTableCard(position, card, layer) + if (name == null) { + nameElement.style.color = "var(--secondary-text-color)" + nameElement.innerHTML = missingPlayerText + } else { + nameElement.style.color = active ? "var(--button-color)" : "var(--text-color)" + nameElement.innerHTML = name + } } -function showConnectionState(position, connected) { +function showPlayerDisconnected(position) { + setPlayerState(position, "var(--alert-color)", offlineText) +} + +function showPlayerState(position, state) { + setPlayerState(position, "var(--secondary-text-color)", state) +} + +function setPlayerState(position, color, text) { const connectionElement = "table-player-state-" + position - setDisplayStyle(connectionElement, connected ? "none" : "inherit") + const element = document.getElementById(connectionElement) + element.style.color = color + element.innerHTML = text } function setTableCard(position, card, layer) { @@ -206,9 +221,14 @@ function updateTableInfo(table) { setHandCard(i+1, "", false) } + let playedGame = null + if (table.hasOwnProperty("game")) { + playedGame = textForAction(table.game) + } + // Show player info console.log(table) - setInfoForPlayer(table.player, "bottom") + setInfoForPlayer(table.player, "bottom", playedGame) if (table.playerSelectsGame) { setActionsForOwnPlayer(table.playableGames) showAvailableGames([]) @@ -221,29 +241,61 @@ function updateTableInfo(table) { setActionsForOwnPlayer(table.actions) } if (table.hasOwnProperty("playerLeft")) { - setInfoForPlayer(table.playerLeft, "left") + setInfoForPlayer(table.playerLeft, "left", playedGame) } else { setEmptyPlayerInfo("left") } if (table.hasOwnProperty("playerAcross")) { - setInfoForPlayer(table.playerAcross, "top") + setInfoForPlayer(table.playerAcross, "top", playedGame) } else { setEmptyPlayerInfo("top") } if (table.hasOwnProperty("playerRight")) { - setInfoForPlayer(table.playerRight, "right") + setInfoForPlayer(table.playerRight, "right", playedGame) } else { setEmptyPlayerInfo("right") } } -function setInfoForPlayer(player, position) { +function setInfoForPlayer(player, position, game) { var card = "" - if (player.hasOwnProperty("playedCard")) { - card = player.playedCard + if (player.hasOwnProperty("card")) { + card = player.card } + const leadsGame = player.leads const layer = player.position - setTablePlayerInfo(position, player.name, player.connected, player.active, card, layer) + setTableCard(position, card, layer) + setTablePlayerName(position, player.name, player.active) + if (!player.connected) { + showPlayerDisconnected(position) + return + } + var state = [] + if (game != null && leadsGame) { + state.push(game) + } + + const double = doubleText(player.doubles) + if (double) { + state.push(double) + } + + if (game != null) { + state.push(player.points.toString() + " Punkte") + } + + const text = state.join(", ") + showPlayerState(position, text) +} + +function doubleText(doubles) { + if (doubles == 0) { + return null + } + if (doubles == 1) { + return "gedoppelt" + } + return doubles.toString() + "x gedoppelt" } function clearInfoForPlayer(position) { @@ -296,6 +348,8 @@ function textForAction(action) { case "raise": return "Schießen" + case "ruf": + return "Ruf" case "ruf-eichel": return "Ruf Eichel" case "ruf-blatt": @@ -308,6 +362,8 @@ function textForAction(action) { return "Wenz" case "geier": return "Geier" + case "solo": + return "Solo" case "solo-eichel": return "Eichel Solo" case "solo-blatt": diff --git a/Readme.md b/Readme.md index 721a098..f4180d5 100644 --- a/Readme.md +++ b/Readme.md @@ -94,5 +94,7 @@ Version 3: - Save data persistently - Table administrator can remove players +# Bugs +- Correctly show available games diff --git a/Sources/App/Extensions/Array+Extensions.swift b/Sources/App/Extensions/Array+Extensions.swift index 87577fc..b53f281 100644 --- a/Sources/App/Extensions/Array+Extensions.swift +++ b/Sources/App/Extensions/Array+Extensions.swift @@ -1,6 +1,27 @@ import Foundation extension Array { + + func at(_ index: Int) -> Element? { + guard index < count else { + return nil + } + return self[index] + } + + func rotatedByOne() -> [Element] { + guard !isEmpty else { + return [] + } + return self[1...] + [self[0]] + } + + mutating func rotateByOne() { + guard !isEmpty else { + return + } + append(removeFirst()) + } func rotated(toStartAt index: Int) -> [Element] { guard index != 0 else { @@ -13,3 +34,35 @@ extension Array { sorted { converting($0) < converting($1) } } } + +extension Array where Element: Equatable { + + func index(of element: Element) -> Index { + firstIndex(of: element)! + } +} + +extension Array where Element: Player { + + var names: [PlayerName] { + map { $0.name } + } + + func index(of player: PlayerName) -> Int { + firstIndex { $0.name == player }! + } + + func player(named name: PlayerName) -> Element? { + first { $0.name == name } + } + + func contains(player: PlayerName) -> Bool { + contains { $0.name == player } + } + + func next(after player: Element) -> Element { + let i = index(of: player) + let newIndex = (i + 1) % maximumPlayersPerTable + return self[newIndex] + } +} diff --git a/Sources/App/Infos/PlayerInfo.swift b/Sources/App/Infos/PlayerInfo.swift index 3ee3608..59aa25b 100644 --- a/Sources/App/Infos/PlayerInfo.swift +++ b/Sources/App/Infos/PlayerInfo.swift @@ -17,12 +17,22 @@ struct PlayerInfo: Codable, Equatable { /// The height of the player card on the table stack let positionInTrick: Int - init(player: Player, isNextActor: Bool, position: Int) { + /// The number of times the player doubled the game cost (initial double and raises) + let numberOfDoubles: Int + + let leadsGame: Bool + + let points: Int? + + init(player: Player, position: Int) { self.name = player.name self.isConnected = player.isConnected - self.isNextActor = isNextActor + self.isNextActor = player.isNextActor self.positionInTrick = position self.playedCard = player.playedCard?.id + self.numberOfDoubles = player.numberOfDoubles + self.leadsGame = player.leadsGame + self.points = player.points } /// Convert the property names into shorter strings for JSON encoding @@ -32,5 +42,8 @@ struct PlayerInfo: Codable, Equatable { case isNextActor = "active" case playedCard = "card" case positionInTrick = "position" + case numberOfDoubles = "doubles" + case leadsGame = "leads" + case points = "points" } } diff --git a/Sources/App/Infos/TableInfo.swift b/Sources/App/Infos/TableInfo.swift index fcf5840..2a000bd 100644 --- a/Sources/App/Infos/TableInfo.swift +++ b/Sources/App/Infos/TableInfo.swift @@ -23,23 +23,22 @@ struct TableInfo: Codable { let actions: [ActionId] let playerSelectsGame: Bool + + let game: GameId? - init(id: String, name: String, - own: PlayerInfo, left: PlayerInfo?, - across: PlayerInfo?, right: PlayerInfo?, - games: [GameType] = [], actions: [PlayerAction], - cards: [PlayableCard], - selectGame: Bool = false) { - self.id = id - self.name = name - self.player = own - self.playerLeft = left - self.playerAcross = across - self.playerRight = right - self.playableGames = games.map { $0.id } - self.actions = actions.map { $0.id } - self.cards = cards.map { $0.cardInfo } - self.playerSelectsGame = selectGame + init(table: AbstractTable, index: Int) { + self.id = table.id + self.name = table.name + self.player = table.playerInfo(forIndex: index)! + self.playerLeft = table.playerInfo(forIndex: (index + 1) % 4) + self.playerAcross = table.playerInfo(forIndex: (index + 2) % 4) + self.playerRight = table.playerInfo(forIndex: (index + 3) % 4) + let data = table.playerData(at: index) + self.playableGames = data.games.map { $0.id } + self.actions = data.actions.map { $0.id } + self.cards = data.cards.map { $0.cardInfo } + self.playerSelectsGame = data.selectsGame + self.game = table.playedGame?.id } } diff --git a/Sources/App/Management/TableManagement.swift b/Sources/App/Management/TableManagement.swift index a94af6b..627da99 100644 --- a/Sources/App/Management/TableManagement.swift +++ b/Sources/App/Management/TableManagement.swift @@ -10,7 +10,7 @@ typealias TableName = String final class TableManagement: DiskWriter { /// All tables indexed by their id - private var tables = [TableId : Table]() + private var tables = [TableId : ManageableTable]() /// The handle to the file where the tables are persisted let storageFile: FileHandle @@ -48,9 +48,7 @@ final class TableManagement: DiskWriter { } } entries.forEach { id, tableData in - let table = WaitingTable(id: id, name: tableData.name, isPublic: tableData.isPublic) - tableData.players.forEach { _ = table.add(player: $0) } - tables[id] = table + tables[id] = WaitingTable(id: id, name: tableData.name, isPublic: tableData.isPublic, players: tableData.players) } print("Loaded \(tables.count) tables") } @@ -63,7 +61,7 @@ final class TableManagement: DiskWriter { - Returns: `true`, if the entry was written, `false` on error */ @discardableResult - private func writeTableToDisk(table: Table) -> Bool { + private func writeTableToDisk(table: ManageableTable) -> Bool { let visible = table.isPublic ? "public" : "private" let players = table.playerNames .joined(separator: ",") @@ -94,8 +92,7 @@ final class TableManagement: DiskWriter { - Returns: The table id */ func createTable(named name: TableName, player: PlayerName, isPublic: Bool) -> TableInfo { - let table = WaitingTable(newTable: name, isPublic: isPublic) - _ = table.add(player: player) + let table = WaitingTable(newTable: name, isPublic: isPublic, creator: player) tables[table.id] = table writeTableToDisk(table: table) return table.tableInfo(forPlayer: player) @@ -115,8 +112,8 @@ final class TableManagement: DiskWriter { currentTable(for: player)?.tableInfo(forPlayer: player) } - private func currentTable(for player: PlayerName) -> Table? { - tables.values.first(where: { $0.contains(player: player) }) + private func currentTable(for player: PlayerName) -> ManageableTable? { + tables.values.first(where: { $0.playerNames.contains(player) }) } /** @@ -154,6 +151,7 @@ final class TableManagement: DiskWriter { guard let oldTable = currentTable(for: player) else { return } + /// `player.canStartGame` is automatically set to false, because table is not full let table = WaitingTable(oldTable: oldTable, removing: player) tables[table.id] = table table.sendUpdateToAllPlayers() diff --git a/Sources/App/Model/Card/Card+Array.swift b/Sources/App/Model/Card/Card+Array.swift index 6991fce..3d1dd5f 100644 --- a/Sources/App/Model/Card/Card+Array.swift +++ b/Sources/App/Model/Card/Card+Array.swift @@ -6,6 +6,14 @@ typealias Hand = [Card] extension Array where Element == Card { + var unplayable: [PlayableCard] { + map { $0.unplayable } + } + + var playable: [PlayableCard] { + map { $0.playable } + } + var points: Int { map { $0.points } .reduce(0, +) diff --git a/Sources/App/Model/Card/Card.swift b/Sources/App/Model/Card/Card.swift index 146d6b8..93aeb65 100644 --- a/Sources/App/Model/Card/Card.swift +++ b/Sources/App/Model/Card/Card.swift @@ -10,6 +10,10 @@ struct Card: Codable { case blatt = "B" case herz = "H" case schelln = "S" + + var ace: Card { + .init(self, .ass) + } } let symbol: Symbol @@ -45,6 +49,18 @@ struct Card: Codable { var points: Int { symbol.points } + + var playable: PlayableCard { + .init(card: self, isPlayable: true) + } + + var unplayable: PlayableCard { + .init(card: self, isPlayable: false) + } + + func playable(_ isPlayable: Bool) -> PlayableCard { + .init(card: self, isPlayable: isPlayable) + } static let allCards: Set = { let all = Card.Suit.allCases.map { suit in diff --git a/Sources/App/Model/GameClass.swift b/Sources/App/Model/GameClass.swift index fa45cd7..50e16d6 100644 --- a/Sources/App/Model/GameClass.swift +++ b/Sources/App/Model/GameClass.swift @@ -9,33 +9,65 @@ extension GameType { case .bettel: return .bettel case .wenz, .geier: - return .wenzGeier + return .wenz case .soloEichel, .soloBlatt, .soloHerz, .soloSchelln: return .solo } } - enum GameClass: Int { - case none = 0 - case ruf = 1 - case bettel = 2 - case wenzGeier = 3 - case solo = 4 + enum GameClass: String { + case none = "none" + case ruf = "ruf" + case hochzeit = "hochzeit" + case bettel = "bettel" + case wenz = "wenz" + case geier = "geier" + case solo = "solo" var cost: Int { switch self { case .none: return 0 case .ruf: return 5 + case .hochzeit: return 10 case .bettel: return 15 - case .wenzGeier, .solo: return 20 + case .wenz, .geier, .solo: return 20 } } - mutating func increase() { - guard self != .solo else { - return + @discardableResult + mutating func increase() -> Bool { + switch self { + case .none: + self = .ruf + case .ruf: + self = .bettel + case .hochzeit: + self = .bettel + case .bettel: + self = .wenz + case .wenz, .geier: + self = .solo + case .solo: + return false + } + return true + } + + func increased() -> GameClass? { + switch self { + case .none: + return .ruf + case .ruf: + return .bettel + case .hochzeit: + return .bettel + case .bettel: + return .wenz + case .wenz, .geier: + return .solo + case .solo: + return nil } - self = .init(rawValue: rawValue + 1)! } var allowsWedding: Bool { @@ -47,18 +79,39 @@ extension GameType { } } + func allows(game: GameType) -> Bool { + availableGames.contains(game) + } + var availableGames: [GameType] { switch self { case .none, .ruf: return GameType.allCases - case .bettel: + case .hochzeit, .bettel: return [.bettel, .wenz, .geier, .soloEichel, .soloBlatt, .soloHerz, .soloSchelln] - case .wenzGeier: + case .wenz, .geier: return [.wenz, .geier, .soloEichel, .soloBlatt, .soloHerz, .soloSchelln] case .solo: return [.soloEichel, .soloBlatt, .soloHerz, .soloSchelln] } } + + var availableClasses: [GameClass] { + switch self { + case .none, .ruf: + return [.ruf, .bettel, .wenz, .geier, .solo] + case .hochzeit, .bettel: + return [.bettel, .wenz, .geier, .solo] + case .wenz, .geier: + return [.wenz, .geier, .solo] + case .solo: + return [.solo] + } + } + + var classesWhenOutbidding: [GameClass] { + increased()?.availableClasses ?? [] + } } } @@ -68,3 +121,10 @@ extension GameType.GameClass: Comparable { lhs.rawValue < rhs.rawValue } } + +extension GameType.GameClass: GameConvertible { + + var id: GameId { + rawValue + } +} diff --git a/Sources/App/Model/GameConvertible.swift b/Sources/App/Model/GameConvertible.swift new file mode 100644 index 0000000..f7f156f --- /dev/null +++ b/Sources/App/Model/GameConvertible.swift @@ -0,0 +1,6 @@ +import Foundation + +protocol GameConvertible { + + var id: GameId { get } +} diff --git a/Sources/App/Model/GameType.swift b/Sources/App/Model/GameType.swift index 42b9a86..ad96cdf 100644 --- a/Sources/App/Model/GameType.swift +++ b/Sources/App/Model/GameType.swift @@ -56,10 +56,6 @@ enum GameType: String, CaseIterable, Codable { gameClass.cost } - var id: GameId { - rawValue - } - var sortingType: CardOrder.Type { switch self { case .wenz: @@ -77,3 +73,11 @@ enum GameType: String, CaseIterable, Codable { } } } + +extension GameType: GameConvertible { + + var id: GameId { + rawValue + } + +} diff --git a/Sources/App/Model/OldPlayer.swift b/Sources/App/Model/OldPlayer.swift index f35732a..fcdaa45 100644 --- a/Sources/App/Model/OldPlayer.swift +++ b/Sources/App/Model/OldPlayer.swift @@ -304,6 +304,7 @@ final class OldPlayer { actions = [] } } + func didFinishGame() { actions = [.deal] } diff --git a/Sources/App/Model/OldTable.swift b/Sources/App/Model/OldTable.swift index 826ec9a..e098e42 100644 --- a/Sources/App/Model/OldTable.swift +++ b/Sources/App/Model/OldTable.swift @@ -380,7 +380,7 @@ final class OldTable { // Remove wedding offers players.forEach { $0.weddingOutbid() } } - #warning("Fix bidding") + // TODO: Remove highest bidder from old player player.didPerformBid() diff --git a/Sources/App/Model/PlayerState.swift b/Sources/App/Model/PlayerState.swift new file mode 100644 index 0000000..f76c029 --- /dev/null +++ b/Sources/App/Model/PlayerState.swift @@ -0,0 +1,23 @@ +import Foundation + +enum PlayerState: String { + + case canDouble + case didDouble + + case isDisconnected + + case mustBid + case didFold + case didBid + case mustPlaceBid + + case isGameSelector + case isWeddingOfferer + case isCalled + + case didRaise + + case isWinner + case isLooser +} diff --git a/Sources/App/Model/Players/AbstractPlayer.swift b/Sources/App/Model/Players/AbstractPlayer.swift deleted file mode 100644 index 94fda94..0000000 --- a/Sources/App/Model/Players/AbstractPlayer.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -import WebSocketKit - -class AbstractPlayer { - - let name: PlayerName - - var socket: WebSocket? - - init(name: PlayerName, socket: WebSocket? = nil) { - self.name = name - self.socket = socket - } - - init(player: Player) { - self.name = player.name - self.socket = player.socket - } -} - -extension AbstractPlayer: Equatable { - - static func == (lhs: AbstractPlayer, rhs: AbstractPlayer) -> Bool { - lhs.name == rhs.name - } -} diff --git a/Sources/App/Model/Players/BiddingPlayer.swift b/Sources/App/Model/Players/BiddingPlayer.swift index dba8c94..c84c277 100644 --- a/Sources/App/Model/Players/BiddingPlayer.swift +++ b/Sources/App/Model/Players/BiddingPlayer.swift @@ -1,51 +1,61 @@ import Foundation import WebSocketKit -final class BiddingPlayer { - - let name: String - - var socket: WebSocket? - - let cards: [PlayableCard] +final class BiddingPlayer: Player { var isStillBidding = true - var isAllowedToOfferWedding = true + var isAllowedToOfferWedding: Bool - var offersWedding = false + var selectsGame = false - var wouldAcceptWedding = false - - init(player: DealingPlayer, cards: [PlayableCard]) { - self.name = player.name - self.socket = player.socket - self.cards = cards - } -} - -extension BiddingPlayer: Player { - - var canOfferWedding: Bool { - rawCards.canOfferWedding + init(player: DealingPlayer) { + isAllowedToOfferWedding = true + super.init(player: player) } - var rawCards: [Card] { - cards.map { $0.card } + init(player: WeddingPlayer) { + isStillBidding = !player.offersWedding + isAllowedToOfferWedding = false + super.init(player: player) } + func canPlay(game: GameType) -> Bool { + guard let suit = game.calledSuit else { + if game == .hochzeit { + return canOfferWedding + } + return true + } + let sorter = game.sortingType + guard sorter.hasCardToCall(suit, in: cards) else { + // Player needs at least one card of the called suit + return false + } + let ace = Card(suit, .ass) + return !cards.contains(ace) + } - var actions: [PlayerAction] { + override var actions: [PlayerAction] { guard isStillBidding else { return [] } - guard canOfferWedding, isAllowedToOfferWedding, !offersWedding else { - return [.increaseOrMatchGame, .withdrawFromAuction] + var actions: [PlayerAction] = isNextActor ? [.increaseOrMatchGame, .withdrawFromAuction] : [] + if canOfferWedding { + actions.append(.offerWedding) } - return [.increaseOrMatchGame, .withdrawFromAuction, .offerWedding] + return actions } - var playedCard: Card? { - nil + override var points: Int? { + get { nil } + set { } + } +} + +extension BiddingPlayer { + + var canOfferWedding: Bool { + cards.canOfferWedding } } diff --git a/Sources/App/Model/Players/DealingPlayer.swift b/Sources/App/Model/Players/DealingPlayer.swift index fd3cac2..4f03220 100644 --- a/Sources/App/Model/Players/DealingPlayer.swift +++ b/Sources/App/Model/Players/DealingPlayer.swift @@ -1,24 +1,36 @@ import Foundation import WebSocketKit -final class DealingPlayer: AbstractPlayer { - - var cards: [PlayableCard] = [] +final class DealingPlayer: Player { var didDouble: Bool? = nil + override var isNextActor: Bool { + get { didDouble == nil } + set { } + } + + override var actions: [PlayerAction] { + didDouble == nil ? [.initialDoubleCost, .noDoubleCost] : [] + } + init(player: WaitingPlayer) { super.init(player: player) } -} -extension DealingPlayer: Player { - - var actions: [PlayerAction] { - didDouble == nil ? [.initialDoubleCost, .noDoubleCost] : [] + override var numberOfDoubles: Int { + get { didDouble == true ? 1 : 0 } + set { } } - var playedCard: Card? { - nil + override var leadsGame: Bool { + get { false } + set { } } + + override var points: Int? { + get { nil } + set { } + } + } diff --git a/Sources/App/Model/Players/FinishedPlayer.swift b/Sources/App/Model/Players/FinishedPlayer.swift new file mode 100644 index 0000000..15aac50 --- /dev/null +++ b/Sources/App/Model/Players/FinishedPlayer.swift @@ -0,0 +1,21 @@ +import Foundation + +final class FinishedPlayer: Player { + + let tricks: [Trick] + + init(player: PlayingPlayer) { + self.tricks = player.wonTricks + super.init(player: player) + } + + override var points: Int? { + get { tricks.map { $0.points }.reduce(0, +) } + set { } + } + + override var actions: [PlayerAction] { + [.deal] + } + +} diff --git a/Sources/App/Model/Players/Player.swift b/Sources/App/Model/Players/Player.swift index 79b7182..9db94b0 100644 --- a/Sources/App/Model/Players/Player.swift +++ b/Sources/App/Model/Players/Player.swift @@ -1,18 +1,57 @@ import Foundation import WebSocketKit -protocol Player: AnyObject { +class Player { - var name: String { get } + let name: PlayerName - var socket: WebSocket? { get set } + var socket: WebSocket? - var playedCard: Card? { get } + var playedCard: Card? - var actions: [PlayerAction] { get } + var isNextActor: Bool - var cards: [PlayableCard] { get } + var cards: [Card] + var numberOfDoubles: Int + + var leadsGame: Bool + + var points: Int? + + init(name: PlayerName, socket: WebSocket? = nil) { + self.name = name + self.socket = socket + self.cards = [] + self.isNextActor = false + self.playedCard = nil + self.numberOfDoubles = 0 + self.leadsGame = false + self.points = nil + } + + init(player: Player) { + self.name = player.name + self.socket = player.socket + self.cards = player.cards + self.isNextActor = false + self.playedCard = player.playedCard + self.numberOfDoubles = player.numberOfDoubles + self.leadsGame = player.leadsGame + self.points = player.points + } + + var actions: [PlayerAction] { + [] + } + +} + +extension Player: Equatable { + + static func == (lhs: Player, rhs: Player) -> Bool { + lhs.name == rhs.name + } } extension Player { @@ -36,6 +75,7 @@ extension Player { self.socket = socket } + @discardableResult func disconnect() -> Bool { guard let socket = socket else { return false @@ -50,8 +90,18 @@ extension Player { } - func send(_ info: TableInfo) { - try? socket?.send(encodeJSON(info)) + @discardableResult + func send(_ info: TableInfo) -> Bool { + guard let socket = socket else { + return false + } + do { + try socket.send(encodeJSON(info)) + } catch { + print("Failed to send info: \(error)") + return false + } + return true } // MARK: Actions @@ -61,3 +111,10 @@ extension Player { } } + +extension Player: CustomStringConvertible { + + var description: String { + name + } +} diff --git a/Sources/App/Model/Players/PlayingPlayer.swift b/Sources/App/Model/Players/PlayingPlayer.swift index 3b0088d..2a2af2a 100644 --- a/Sources/App/Model/Players/PlayingPlayer.swift +++ b/Sources/App/Model/Players/PlayingPlayer.swift @@ -1,30 +1,139 @@ import Foundation import WebSocketKit -final class PlayingPlayer: AbstractPlayer { +/** + Specifies the number of cards of the called suit that a player must have + to be allowed to play any card of the suit instead of having to play the ace. + */ +private let numberOfCardsToProtectAce = 4 - var playedCard: Card? = nil - - var cards: [PlayableCard] - - var leadsGame = false +final class PlayingPlayer: Player { var canStillRaise = true - init(player: BiddingPlayer) { - self.cards = player.cards + var isCalledWithAce: Card? + + /// All tricks won by the player in this game + var wonTricks: [Trick] = [] + + init(player: Player, leads: Bool, calledAce ace: Card?) { super.init(player: player) + leadsGame = leads + if let ace = ace, cards.contains(ace) { + isCalledWithAce = ace + } else { + isCalledWithAce = nil + } } -} -extension PlayingPlayer: Player { - - - var actions: [PlayerAction] { - guard canStillRaise, !leadsGame else { + override var actions: [PlayerAction] { + guard canStillRaise, leadsGame == (isCalledWithAce != nil) else { return [] } return [.doubleDuringGame] } + func play(card: Card) { + playedCard = card + cards = cards.filter { $0 != card } + if card == isCalledWithAce { + leadsGame.toggle() + isCalledWithAce = nil + } + } + + func switchLead() { + leadsGame.toggle() + } + + func sortCards(for game: GameType) { + cards = cards.sortedCards(forGame: game) + } + + func canPlay(card: Card, for trick: Trick, in game: GameType) -> Bool { + playableCards(for: trick, in: game).contains { $0.card == card && $0.isPlayable } + } + + func playableCards(for trick: Trick, in game: GameType) -> [PlayableCard] { + guard isNextActor else { + return cards.unplayable + } + guard cards.count > 1 else { + // Last card can always be played + return cards.playable + } + guard let firstCard = trick.first else { + return playableCardsForStarter(game: game) + } + + let sorter = game.sortingType + + guard sorter.isTrump(firstCard) else { + return playableCardsFollowing(suit: firstCard.suit, game: game) + } + guard !sorter.hasTrump(in: cards) else { + // Must follow with trump + return cards.map { $0.playable(sorter.isTrump($0)) } + } + // Can play any card if not in calling game + guard let suit = game.calledSuit else { + return cards.playable + } + // Can play any card, except the called ace + let ace = Card(suit, .ass) + return cards.map { $0.playable($0 != ace) } + } + + private func playableCardsFollowing(suit playedSuit: Card.Suit, game: GameType) -> [PlayableCard] { + let sorter = game.sortingType + let suitCards = sorter.cards(with: playedSuit, in: cards) + + func followSuit() -> [PlayableCard] { + cards.map { $0.playable(!sorter.isTrump($0) && $0.suit == playedSuit) } + } + + guard let calledSuit = game.calledSuit else { + return suitCards.isEmpty ? cards.playable : followSuit() + } + let ace = Card(calledSuit, .ass) + guard !suitCards.isEmpty else { + // Exclude called ace, all others allowed + return cards.map { $0.playable($0 != ace) } + } + guard calledSuit == playedSuit else { + // Must follow suit (called ace not present) + return followSuit() + } + + // The called suit is played, must commit ace + guard cards.contains(ace) else { + // Must follow suit + return followSuit() + } + // Must play ace + return cards.map { $0.playable($0 == ace) } + } + + private func playableCardsForStarter(game: GameType) -> [PlayableCard] { + guard let suit = game.calledSuit else { + return cards.playable + } + let ace = Card(suit, .ass) + // Check if called ace exists, to prohibit other cards of the same suit + guard cards.contains(ace) else { + return cards.playable + } + // Jodeln + if cards.count == numberOfCardsPerPlayer, + cards.suitCount(suit, in: game) >= numberOfCardsToProtectAce { + return cards.playable + } + + // Only ace allowed for the called suit + return cards.map { $0.playable($0.suit != suit || $0.symbol.isTrumpOrAce) } + } + + var currentPoints: Int { + wonTricks.map { $0.points }.reduce(0, +) + } } diff --git a/Sources/App/Model/Players/WaitingPlayer.swift b/Sources/App/Model/Players/WaitingPlayer.swift index 19ac291..25b2f25 100644 --- a/Sources/App/Model/Players/WaitingPlayer.swift +++ b/Sources/App/Model/Players/WaitingPlayer.swift @@ -1,22 +1,22 @@ import Foundation import WebSocketKit -final class WaitingPlayer: AbstractPlayer { +final class WaitingPlayer: Player { var canStartGame: Bool = false -} -extension WaitingPlayer: Player { - - var actions: [PlayerAction] { + override var actions: [PlayerAction] { canStartGame ? [.deal] : [] } - var cards: [PlayableCard] { - [] + override var leadsGame: Bool { + get { false } + set { } } - var playedCard: Card? { - nil + override var points: Int? { + get { nil } + set { } } + } diff --git a/Sources/App/Model/Players/WeddingPlayer.swift b/Sources/App/Model/Players/WeddingPlayer.swift new file mode 100644 index 0000000..7ce78f5 --- /dev/null +++ b/Sources/App/Model/Players/WeddingPlayer.swift @@ -0,0 +1,89 @@ +import Foundation + +final class WeddingPlayer: Player { + + enum State { + case requiresAction + case offersWedding + case wouldAcceptWedding + case withdrawnFromAuction + case selectsGame + } + + var state: State + + var requiresAction: Bool { + state == .requiresAction + } + + var selectsGame: Bool { + get { + state == .selectsGame + } + set { + state = .selectsGame + } + } + + var wouldAcceptWedding: Bool { + state == .wouldAcceptWedding + } + + var offersWedding: Bool { + state == .offersWedding + } + + init(player: BiddingPlayer, offersWedding: Bool) { + self.state = offersWedding ? .offersWedding : .requiresAction + super.init(player: player) + } + + override var actions: [PlayerAction] { + guard state == .requiresAction else { + return [] + } + return [.increaseOrMatchGame, .withdrawFromAuction, .acceptWedding] + } + + override var isNextActor: Bool { + get { + switch state { + case .requiresAction, .selectsGame: + return true + default: + return false + } + } + set { } + } + + override var points: Int? { + get { nil } + set { } + } + + override var leadsGame: Bool { + get { offersWedding || selectsGame } + set { } + } + + func canExchange(card: Card) -> Bool { + cards.filter { !$0.isTrump(in: .hochzeit) }.contains(card) + } + + var exchangeableCards: [PlayableCard] { + cards.map { $0.playable(!$0.isTrump(in: .hochzeit)) } + } + + func replaceWeddingCard(with card: Card) -> Card { + let ownCardIndex = cards.firstIndex { $0.isTrump(in: .hochzeit)}! + let ownCard = cards.remove(at: ownCardIndex) + cards.append(card) + cards = cards.sortedCards(forGame: .hochzeit) + return ownCard + } + + func replace(_ card: Card, with trumpCard: Card) { + cards = (cards.filter { $0 != card } + [trumpCard]).sortedCards(forGame: .hochzeit) + } +} diff --git a/Sources/App/Model/Tables/AbstractTable.swift b/Sources/App/Model/Tables/AbstractTable.swift index d9ab4c8..1ceef88 100644 --- a/Sources/App/Model/Tables/AbstractTable.swift +++ b/Sources/App/Model/Tables/AbstractTable.swift @@ -1,6 +1,7 @@ import Foundation +import WebSocketKit -class AbstractTable { +class AbstractTable where TablePlayer: Player { /// The unique id of the table let id: TableId @@ -11,15 +12,116 @@ class AbstractTable { /// Indicates that the table is visible to all players, and can be joined by anyone let isPublic: Bool - init(table: AbstractTable) { + /** + The players sitting at the table. + + The players are ordered clockwise around the table, with the first player starting the game. + */ + var players: [TablePlayer] + + var playedGame: GameType? { + nil + } + + init(table: ManageableTable, players: [TablePlayer]) { self.id = table.id self.name = table.name self.isPublic = table.isPublic + self.players = players } - init(id: TableId, name: TableName, isPublic: Bool) { + init(id: TableId, name: TableName, isPublic: Bool, players: [TablePlayer]) { self.id = id self.name = name self.isPublic = isPublic + self.players = players } + + func perform(action: PlayerAction, forPlayer name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { + (.tableStateInvalid, nil) + } + + func play(card: Card, player name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { + (.tableStateInvalid, nil) + } + + func playerData(at index: Int) -> (actions: [PlayerAction], games: [GameConvertible], cards: [PlayableCard], selectsGame: Bool) { + let player = players[index] + return (actions: player.actions, games: [], cards: player.cards.unplayable, selectsGame: false) + } + + func cardStackPosition(ofPlayerAt index: Int) -> Int { + index + } + } + +extension AbstractTable: ManageableTable { + + var publicInfo: PublicTableInfo { + .init(id: id, name: name, players: playerNames) + } + + var playerNames: [PlayerName] { + players.map { $0.name } + } + + var allPlayers: [Player] { + players + } + + + + // MARK: Connection + + func connect(player name: PlayerName, using socket: WebSocket) -> Bool { + guard let player = players.player(named: name) else { + return false + } + player.connect(using: socket) + sendUpdateToAllPlayers() + return true + } + + func disconnect(player name: PlayerName) { + guard let player = players.player(named: name) else { + return + } + guard player.disconnect() else { + return + } + sendUpdateToAllPlayers() + return + } + + func sendUpdateToAllPlayers() { + players.enumerated().forEach { playerIndex, player in + guard player.isConnected else { + return + } + let info = self.tableInfo(forPlayerAt: playerIndex) + player.send(info) + } + } + + // MARK: Client info + + func playerInfo(forIndex index: Int) -> PlayerInfo? { + guard let player = players.at(index) else { + return nil + } + let height = cardStackPosition(ofPlayerAt: index) + return PlayerInfo(player: player, position: height) + } + + func tableInfo(forPlayer player: PlayerName) -> TableInfo { + let index = players.index(of: player) + return tableInfo(forPlayerAt: index) + } + + func tableInfo(forPlayerAt index: Int) -> TableInfo { + .init(table: self, index: index) + } + +} + diff --git a/Sources/App/Model/Tables/BiddingTable.swift b/Sources/App/Model/Tables/BiddingTable.swift index 3ab5c74..b9e5b62 100644 --- a/Sources/App/Model/Tables/BiddingTable.swift +++ b/Sources/App/Model/Tables/BiddingTable.swift @@ -1,51 +1,181 @@ import Foundation -final class BiddingTable: AbstractTable { +final class BiddingTable: AbstractTable { + + var gameToOutbid: GameType.GameClass = .none - var players: [BiddingPlayer] + var indexOfHighestBidder = 0 - var hasSelectedGame: Bool { - // TODO: Implement - false + var remainingBidders: Int { + players.filter { $0.isStillBidding }.count + } + + var isWaitingForGameSelection: Bool { + players.contains { $0.selectsGame } } init(table: DealingTable) { - self.players = table.players.map { - BiddingPlayer(player: $0, cards: []) + // Add new cards to the players + let newCards = Dealer.dealRemainingCards(of: table.players.map { $0.cards }) + let players: [BiddingPlayer] = table.players.enumerated().map { index, player in + player.cards = (player.cards + newCards[index]) + .sortedCards(order: NormalCardOrder.self) + player.isNextActor = false + return BiddingPlayer(player: player) } - super.init(table: table) + players.first!.isNextActor = true + super.init(table: table, players: players) } - func select(game: GameType, player: PlayerName) -> (result: PlayerActionResult, table: Table?) { - // TODO: Implement - return (.tableStateInvalid, nil) + init(wedding table: WeddingTable, outbidBy player: WeddingPlayer) { + gameToOutbid = .hochzeit + indexOfHighestBidder = table.players.index(of: player) + // All players can bid again, except the wedding offerer + let players = table.players.map(BiddingPlayer.init) + players[indexOfHighestBidder].isNextActor = true + super.init(table: table, players: players) + + // Choose the player after the one who discarded the wedding + selectNextBidder() } - func makePlayingTable() -> PlayingTable { - // TODO: Implement - fatalError() - } -} + func select(game: GameType, player name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { + guard let player = players.player(named: name) else { + print("Player \(name) unexpectedly missing from bidding table \(self.name)") + return (.tableStateInvalid, nil) + } + guard player.selectsGame else { + print("Player \(name) does not select the game") + return (.tableStateInvalid, nil) + } + guard gameToOutbid.allows(game: game) else { + print("Game \(game) not allowed for class \(gameToOutbid)") + return (.tableStateInvalid, nil) + } + guard player.canPlay(game: game) else { + print("Player \(game) can't play game \(game)") + return (.tableStateInvalid, nil) + } -extension BiddingTable: Table { - - var allPlayers: [Player] { - players + let table = PlayingTable(table: self, game: game, playedBy: player) + return (.success, table) } - var indexOfNextActor: Int { - // TODO: Implement - return 0 + @discardableResult + private func selectNextBidder() -> Bool { + guard let index = players.firstIndex(where: { $0.isNextActor }) else { + print("Bidding: No current actor found to select next bidder") + return false + } + players[index].isNextActor = false + let newActor = players.rotated(toStartAt: (index + 1) % 4).first(where: { $0.isStillBidding })! + newActor.isNextActor = true + return true } - func perform(action: PlayerAction, forPlayer: PlayerName) -> (result: PlayerActionResult, table: Table?) { - // TODO: Implement bidding actions - return (.tableStateInvalid, nil) + override func perform(action: PlayerAction, forPlayer name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { + guard let player = players.player(named: name) else { + print("Player \(name) unexpectedly missing from bidding table \(self.name)") + return (.tableStateInvalid, nil) + } + guard player.canPerform(action) else { + return (.tableStateInvalid, nil) + } + + switch action { + case .offerWedding: + return performWeddingOffer(forPlayer: player) + case .increaseOrMatchGame: + return performBidIncrease(forPlayer: player) + case .withdrawFromAuction: + return performWithdrawl(forPlayer: player) + default: + return (.tableStateInvalid, nil) + } } - func play(card: Card, player name: PlayerName) -> (result: PlayerActionResult, table: Table?) { - // TODO: Implement for wedding - return (.tableStateInvalid, nil) + private func performWeddingOffer(forPlayer player: BiddingPlayer) -> (result: PlayerActionResult, table: ManageableTable?) { + guard gameToOutbid.allowsWedding else { + return (.tableStateInvalid, nil) + } + let newTable = WeddingTable(table: self, offerer: player) + return (.success, newTable) + } + + private func performBidIncrease(forPlayer player: BiddingPlayer) -> (result: PlayerActionResult, table: ManageableTable?) { + guard player.isNextActor, player.isStillBidding else { + return (.tableStateInvalid, nil) + } + let index = players.index(of: player) + if index < indexOfHighestBidder { + // Player sits before the current highest bidder, so only needs to match the game + indexOfHighestBidder = index + if gameToOutbid == .solo { + // Can't be outbid, so player selects game + players.forEach { $0.isStillBidding = false } + player.selectsGame = true + return (.success, nil) + } + // TODO: Check that wedding can be offered at the correct times + // There may be a case where a player sitting before the highest bidder + // can't offer a wedding anymore although it should be able to + if !gameToOutbid.allowsWedding { + players.forEach { $0.isAllowedToOfferWedding = false } + } + } else { + // Player sits after the highest bidder, so must outbid the game + // Also the case when first starting bidding + gameToOutbid.increase() + indexOfHighestBidder = index + if !gameToOutbid.allowsWedding { + players.forEach { $0.isAllowedToOfferWedding = false } + } + } + selectNextBidder() + return (.success, nil) + } + + private func performWithdrawl(forPlayer player: BiddingPlayer) -> (result: PlayerActionResult, table: ManageableTable?) { + guard player.isStillBidding else { + return (.tableStateInvalid, nil) + } + player.isStillBidding = false + switch remainingBidders { + case 0: + // Nobody wants to play something, so abort the game + // This case can only be reached when nobody has bid yet + let table = WaitingTable(oldTableAdvancedByOne: self) + return (.success, table) + case 1: + if gameToOutbid != .none { + // Last player must play + player.isNextActor = false + indexOfHighestBidder = players.firstIndex { $0.isStillBidding == true }! + let highestPlayer = players[indexOfHighestBidder] + highestPlayer.isStillBidding = false + highestPlayer.selectsGame = true + highestPlayer.isNextActor = true + return (.success, nil) + } + default: + break + } + selectNextBidder() + return (.success, nil) + } + + override func playerData(at index: Int) -> (actions: [PlayerAction], games: [GameConvertible], cards: [PlayableCard], selectsGame: Bool) { + let player = players[index] + + let games: [GameConvertible] + if isWaitingForGameSelection { + games = gameToOutbid.availableGames.filter(player.canPlay) + } else if index <= indexOfHighestBidder { + games = gameToOutbid.availableClasses + } else { + games = gameToOutbid.classesWhenOutbidding + } + return (player.actions, games, player.cards.unplayable, selectsGame: player.selectsGame) } } diff --git a/Sources/App/Model/Tables/DealingTable.swift b/Sources/App/Model/Tables/DealingTable.swift index 219e8bb..80c516d 100644 --- a/Sources/App/Model/Tables/DealingTable.swift +++ b/Sources/App/Model/Tables/DealingTable.swift @@ -1,39 +1,61 @@ import Foundation -final class DealingTable: AbstractTable { - - var players: [DealingPlayer] +final class DealingTable: AbstractTable { init(table: WaitingTable) { - self.players = table.players.map(DealingPlayer.init) - super.init(table: table) - let cards = Dealer.dealFirstCards() - for (index, player) in players.enumerated() { - player.cards = cards[index].map { .init(card: $0, isPlayable: false) } + for (index, player) in table.players.enumerated() { + player.cards = cards[index] + } + let players = table.players.map(DealingPlayer.init) + super.init(table: table, players: players) + } + + /// All players either doubled or didn't double + var allPlayersActed: Bool { + !players.contains { $0.didDouble == nil } + } + + /// At least one player placed a bid + var hasDouble: Bool { + players.contains { $0.didDouble == true } + } + + override func perform(action: PlayerAction, forPlayer name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { + guard let player = players.player(named: name) else { + print("Player \(name) unexpectedly missing from dealing table \(self.name)") + return (.tableStateInvalid, nil) + } + guard player.canPerform(action) else { + return (.tableStateInvalid, nil) + } + + switch action { + case .initialDoubleCost: + return perform(double: true, forPlayer: player) + case .noDoubleCost: + return perform(double: false, forPlayer: player) + default: + return (.tableStateInvalid, nil) } } -} - -extension DealingTable: Table { - - var allPlayers: [Player] { - players - } - - var indexOfNextActor: Int { - // TODO: Implement - return 0 - } - - func perform(action: PlayerAction, forPlayer: PlayerName) -> (result: PlayerActionResult, table: Table?) { - // TODO: Implement doubling, additional cards - return (.tableStateInvalid, nil) - } - - func play(card: Card, player name: PlayerName) -> (result: PlayerActionResult, table: Table?) { - // No cards playable while dealing - (.tableStateInvalid, nil) - } - + + private func perform(double: Bool, forPlayer player: DealingPlayer) -> (result: PlayerActionResult, table: ManageableTable?) { + guard player.didDouble == nil else { + return (.tableStateInvalid, nil) + } + player.didDouble = double + guard allPlayersActed else { + return (.success, nil) + } + guard hasDouble else { + // Revert to a waiting table and switch to the next player + let table = WaitingTable(oldTableAdvancedByOne: self) + return (.success, table) + } + // Automatically adds remaining cards to the players + let table = BiddingTable(table: self) + return (.success, table) + } + } diff --git a/Sources/App/Model/Tables/FinishedTable.swift b/Sources/App/Model/Tables/FinishedTable.swift new file mode 100644 index 0000000..2ba35eb --- /dev/null +++ b/Sources/App/Model/Tables/FinishedTable.swift @@ -0,0 +1,83 @@ +import Foundation + +final class FinishedTable: AbstractTable { + + let game: GameType + + var winners: [FinishedPlayer] { + leadersHaveWon ? leaders : opponents + } + + var loosers: [FinishedPlayer] { + leadersHaveWon ? opponents : leaders + } + + var leaders: [FinishedPlayer] { + players.filter { $0.leadsGame } + } + + var opponents: [FinishedPlayer] { + players.filter { !$0.leadsGame } + } + + var winningPoints: Int { + leadersHaveWon ? leadingPoints : 120 - leadingPoints + } + + var loosingPoints: Int { + leadersHaveWon ? 120 - leadingPoints : leadingPoints + } + + let leadingPoints: Int + + var leadersHaveWon: Bool { + leadingPoints > 60 + } + + var isSchwarz: Bool { + loosingPoints == 0 + } + + var isSchneider: Bool { + loosingPoints < (leadersHaveWon ? 30 : 31) + } + + override var playedGame: GameType? { + game + } + + init(table: PlayingTable) { + let players = table.players.map(FinishedPlayer.init) + self.game = table.game + leadingPoints = players + .filter { $0.leadsGame } + .map { $0.points! } + .reduce(0, +) + super.init(table: table, players: players) + } + + /** + Perform a deal action on the finished table. + + - Parameter action: The action to perform + - Parameter player: The name of the player + */ + override func perform(action: PlayerAction, forPlayer name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { + // Only dealing is allowed... + guard action == .deal else { + return (.tableStateInvalid, nil) + } + guard let player = players.player(named: name) else { + print("Unexpectedly missing player \(name) for deal action at finished table \(self.name)") + return (.tableStateInvalid, nil) + } + + guard player.canPerform(.deal) else { + print("Finished table: Player \(name) can't perform deal") + return (.tableStateInvalid, nil) + } + let waiting = WaitingTable(oldTableAdvancedByOne: self) + let table = DealingTable(table: waiting) + return (.success, table) + } +} diff --git a/Sources/App/Model/Tables/ManageableTable.swift b/Sources/App/Model/Tables/ManageableTable.swift new file mode 100644 index 0000000..f3656ce --- /dev/null +++ b/Sources/App/Model/Tables/ManageableTable.swift @@ -0,0 +1,32 @@ +import Foundation +import WebSocketKit + +protocol ManageableTable { + + /// The unique id of the table + var id: TableId { get } + + /// The name of the table + var name: TableName { get } + + /// The table is visible in the list of tables and can be joined by anyone + var isPublic: Bool { get } + + var playerNames: [PlayerName] { get } + + var allPlayers: [Player] { get } + + var publicInfo: PublicTableInfo { get } + + func tableInfo(forPlayer player: PlayerName) -> TableInfo + + func perform(action: PlayerAction, forPlayer: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) + + func play(card: Card, player name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) + + func connect(player name: PlayerName, using socket: WebSocket) -> Bool + + func disconnect(player name: PlayerName) + + func sendUpdateToAllPlayers() +} diff --git a/Sources/App/Model/Tables/PlayingTable.swift b/Sources/App/Model/Tables/PlayingTable.swift index 04ee339..7764c02 100644 --- a/Sources/App/Model/Tables/PlayingTable.swift +++ b/Sources/App/Model/Tables/PlayingTable.swift @@ -1,34 +1,144 @@ import Foundation -final class PlayingTable: AbstractTable { +final class PlayingTable: AbstractTable { - var players: [PlayingPlayer] + let game: GameType - init(table: BiddingTable) { - self.players = table.players.map(PlayingPlayer.init) - super.init(table: table) + var indexOfTrickStarter = 0 + + var didDoubleInCurrentRound = false + + var hasCompletedTrick: Bool { + !players.contains { $0.playedCard == nil } + } + + var nextTrick: [Card] { + hasCompletedTrick ? [] : currentTrick + } + + var currentTrick: [Card] { + players.rotated(toStartAt: indexOfTrickStarter).compactMap { $0.playedCard } + } + + var completedTrick: Trick? { + let trick = currentTrick + guard trick.count == maximumPlayersPerTable else { + return nil + } + return trick + } + + var allCardsPlayed: Bool { + !players.contains { !$0.cards.isEmpty } + } + + override var playedGame: GameType? { + game + } + + convenience init(table: BiddingTable, game: GameType, playedBy player: BiddingPlayer) { + let calledAce = game.calledSuit?.ace + let players = table.players.map { + PlayingPlayer(player: $0, leads: $0 == player, calledAce: calledAce) + } + self.init(table: table, players: players, game: game) + } + + convenience init(wedding table: WeddingTable, offeredBy offerer: WeddingPlayer, acceptedBy player: WeddingPlayer) { + let players = table.players.map { + PlayingPlayer(player: $0, leads: $0 == player || $0 == offerer, calledAce: nil) + } + self.init(table: table, players: players, game: .hochzeit) + } + + private init(table: ManageableTable, players: [PlayingPlayer], game: GameType) { + self.game = game + super.init(table: table, players: players) + players.forEach { $0.sortCards(for: game) } + players.first!.isNextActor = true + } + + override func cardStackPosition(ofPlayerAt index: Int) -> Int { + (4 + index - indexOfTrickStarter) % 4 + } + + override func perform(action: PlayerAction, forPlayer name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { + guard let player = players.player(named: name) else { + print("Player \(name) unexpectedly missing from playing table \(self.name)") + return (.tableStateInvalid, nil) + } + guard action == .doubleDuringGame else { + print("Player \(name) wants to perform action \(action) on playing table") + return (.tableStateInvalid, nil) + } + guard player.canPerform(.doubleDuringGame) else { + print("Player \(name) is not allowed to raise") + return (.tableStateInvalid, nil) + } + player.numberOfDoubles += 1 + players.forEach { $0.switchLead() } + self.didDoubleInCurrentRound = true + return (.success, nil) + } + + override func play(card: Card, player name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { + guard let player = players.player(named: name) else { + print("Player \(name) unexpectedly missing from playing table \(self.name)") + return (.tableStateInvalid, nil) + } + guard player.isNextActor else { + print("Player \(name) wants to play card but is not active") + return (.tableStateInvalid, nil) + } + guard player.canPlay(card: card, for: nextTrick, in: game) else { + return (.tableStateInvalid, nil) + } + if hasCompletedTrick { + players.forEach { $0.playedCard = nil } + indexOfTrickStarter = players.index(of: player) + } + player.play(card: card) + if let completedTrick = completedTrick { + return didFinish(trick: completedTrick, in: game) + } else { + let next = players.next(after: player) + next.isNextActor = true + player.isNextActor = false + return (.success, nil) + } + } + + override func playerData(at index: Int) -> (actions: [PlayerAction], games: [GameConvertible], cards: [PlayableCard], selectsGame: Bool) { + let player = players[index] + let cards = player.playableCards(for: nextTrick, in: game) + return (actions: player.actions, games: [], cards: cards, selectsGame: false) + } + + private func didFinish(trick: Trick, in game: GameType) -> (result: PlayerActionResult, table: ManageableTable?) { + let index = trick.highCardIndex(forGame: game) + let winner = players[(indexOfTrickStarter + index) % 4] + players.forEach { + $0.isNextActor = false + $0.canStillRaise = didDoubleInCurrentRound + } + winner.wonTricks.append(trick) + winner.isNextActor = true + + if game == .bettel && winner.leadsGame { + // A bettel is lost if a single trick is won by the leader + return finishedGame() + } + + didDoubleInCurrentRound = false + if allCardsPlayed { + return finishedGame() + } + return (.success, nil) + } + + private func finishedGame() -> (result: PlayerActionResult, table: ManageableTable?) { + let table = FinishedTable(table: self) + print("\(table.winners) have won with \(table.winningPoints) to \(table.loosingPoints) points") + return (.success, table) } } - -extension PlayingTable: Table { - - var allPlayers: [Player] { - players - } - - var indexOfNextActor: Int { - // TODO: Implement - return 0 - } - - func perform(action: PlayerAction, forPlayer: PlayerName) -> (result: PlayerActionResult, table: Table?) { - // TODO: Implement raises - return (.tableStateInvalid, nil) - } - - func play(card: Card, player name: PlayerName) -> (result: PlayerActionResult, table: Table?) { - // TODO: Implement playing of cards - return (.tableStateInvalid, nil) - } - -} diff --git a/Sources/App/Model/Tables/Table.swift b/Sources/App/Model/Tables/Table.swift deleted file mode 100644 index f592829..0000000 --- a/Sources/App/Model/Tables/Table.swift +++ /dev/null @@ -1,121 +0,0 @@ -import Foundation -import WebSocketKit - -protocol Table: AbstractTable { - - /// The unique id of the table - var id: TableId { get } - - /// The name of the table - var name: TableName { get } - - /// The table is visible in the list of tables and can be joined by anyone - var isPublic: Bool { get } - - /** - The players sitting at the table. - - The players are ordered clockwise around the table, with the first player starting the game. - */ - var allPlayers: [Player] { get } - - var indexOfNextActor: Int { get } - - func perform(action: PlayerAction, forPlayer: PlayerName) -> (result: PlayerActionResult, table: Table?) - - func play(card: Card, player name: PlayerName) -> (result: PlayerActionResult, table: Table?) -} - -extension Table { - - var playerNames: [String] { - allPlayers.map { $0.name } - } - - - func index(of player: PlayerName) -> Int { - allPlayers.firstIndex { $0.name == player }! - } - - func player(named name: PlayerName) -> Player? { - allPlayers.first { $0.name == name } - } - - func contains(player: PlayerName) -> Bool { - allPlayers.contains { $0.name == player } - } - - // MARK: Connection - - func sendUpdateToAllPlayers() { - allPlayers.enumerated().forEach { playerIndex, player in - guard player.isConnected else { - return - } - let info = self.tableInfo(forPlayerAt: playerIndex) - player.send(info) - } - } - - func connect(player name: PlayerName, using socket: WebSocket) -> Bool { - guard let player = player(named: name) else { - return false - } - player.connect(using: socket) - sendUpdateToAllPlayers() - return true - } - - func disconnect(player name: PlayerName) { - guard let player = player(named: name) else { - return - } - guard player.disconnect() else { - return - } - sendUpdateToAllPlayers() - return - } - - // MARK: Client info - - var publicInfo: PublicTableInfo { - .init(id: id, name: name, players: playerNames) - } - - private func player(forIndex index: Int) -> Player? { - let players = allPlayers - guard index < players.count else { - return nil - } - return players[index] - } - - private func playerInfo(forIndex index: Int) -> PlayerInfo? { - guard let player = player(forIndex: index) else { - return nil - } - let isNext = indexOfNextActor == index - return PlayerInfo(player: player, isNextActor: isNext, position: index) - } - - func tableInfo(forPlayer player: PlayerName) -> TableInfo { - let index = index(of: player) - return tableInfo(forPlayerAt: index) - } - - func tableInfo(forPlayerAt index: Int) -> TableInfo { - let player = player(forIndex: index)! - let own = playerInfo(forIndex: index)! - let left = playerInfo(forIndex: (index + 1) % 4) - let across = playerInfo(forIndex: (index + 2) % 4) - let right = playerInfo(forIndex: (index + 3) % 4) - return .init( - id: id, name: name, - own: own, left: left, - across: across, right: right, - actions: player.actions, - cards: player.cards) - } - -} diff --git a/Sources/App/Model/Tables/WaitingTable.swift b/Sources/App/Model/Tables/WaitingTable.swift index 4ba9bc3..04228ad 100644 --- a/Sources/App/Model/Tables/WaitingTable.swift +++ b/Sources/App/Model/Tables/WaitingTable.swift @@ -3,22 +3,20 @@ import Foundation /** Represents a table where players are still joining and leaving. */ -final class WaitingTable: AbstractTable { - - /** - The players sitting at the table. - - The players are ordered clockwise around the table, with the first player starting the game. - */ - var players: [WaitingPlayer] = [] +final class WaitingTable: AbstractTable { /// The table contains enough players to start a game var isFull: Bool { players.count >= maximumPlayersPerTable } - override init(id: TableId, name: TableName, isPublic: Bool) { - super.init(id: id, name: name, isPublic: isPublic) + init(id: TableId, name: TableName, isPublic: Bool, players: [PlayerName]) { + let players = players.map { WaitingPlayer(name: $0) } + players.first!.isNextActor = true + super.init(id: id, name: name, isPublic: isPublic, players: players) + if isFull { + self.players.forEach { $0.canStartGame = true } + } } /** @@ -26,8 +24,10 @@ final class WaitingTable: AbstractTable { - Parameter name: The name of the table - Parameter isPublic: The table is visible and joinable by everyone */ - init(newTable name: TableName, isPublic: Bool) { - super.init(id: .newToken(), name: name, isPublic: isPublic) + init(newTable name: TableName, isPublic: Bool, creator: PlayerName) { + let player = WaitingPlayer(name: creator) + player.isNextActor = true + super.init(id: .newToken(), name: name, isPublic: isPublic, players: [player]) } /** @@ -35,13 +35,35 @@ final class WaitingTable: AbstractTable { This is needed when a player leaves an active table. - Parameter oldTable: The table to convert - - Parameter player: The player to remove from the table. + - Parameter player: The name of the player to remove from the table. */ - init(oldTable: Table, removing player: PlayerName) { - self.players = oldTable.allPlayers - .filter { $0.name != player } - .map(WaitingPlayer.init) - super.init(table: oldTable) + init(oldTable: ManageableTable, removing player: PlayerName) { + let players = oldTable.allPlayers + .filter { + guard $0.name == player else { + return true + } + _ = $0.disconnect() + return false + } + .map { WaitingPlayer(name: $0.name, socket: $0.socket) } + players.first!.isNextActor = true + super.init(table: oldTable, players: players) + } + + /** + Convert another table to a waiting table. + + This is needed when a player leaves an active table. + - Parameter oldTable: The table to convert + */ + init(oldTableAdvancedByOne table: ManageableTable) { + let players = table.allPlayers + .rotatedByOne() + .map { WaitingPlayer(name: $0.name, socket: $0.socket) } + super.init(table: table, players: players) + players.forEach { $0.canStartGame = true } + players.first!.isNextActor = true } /** @@ -69,7 +91,7 @@ final class WaitingTable: AbstractTable { - Parameter action: The action to perform - Parameter player: The name of the player */ - func perform(action: PlayerAction, forPlayer name: PlayerName) -> (result: PlayerActionResult, table: Table?) { + override func perform(action: PlayerAction, forPlayer name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { // Only dealing is allowed... guard action == .deal else { return (.tableStateInvalid, nil) @@ -78,7 +100,7 @@ final class WaitingTable: AbstractTable { guard isFull else { return (.tableStateInvalid, nil) } - guard let player = player(named: name) else { + guard let player = players.player(named: name) else { print("Unexpected action \(action) for missing player \(name) at table \(self.name)") return (.tableStateInvalid, nil) } @@ -90,20 +112,8 @@ final class WaitingTable: AbstractTable { let table = DealingTable(table: self) return (.success, table) } -} -extension WaitingTable: Table { - - var allPlayers: [Player] { - players as [Player] - } - - var indexOfNextActor: Int { - // The first player at the table starts the game - 0 - } - - func play(card: Card, player name: PlayerName) -> (result: PlayerActionResult, table: Table?) { + override func play(card: Card, player name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { // No cards playable while waiting (.tableStateInvalid, nil) } diff --git a/Sources/App/Model/Tables/WeddingTable.swift b/Sources/App/Model/Tables/WeddingTable.swift new file mode 100644 index 0000000..4f42fff --- /dev/null +++ b/Sources/App/Model/Tables/WeddingTable.swift @@ -0,0 +1,121 @@ +import Foundation + +final class WeddingTable: AbstractTable { + + var indexOfWeddingOffer: Int + + init(table: BiddingTable, offerer: BiddingPlayer) { + let players = table.players.map { WeddingPlayer(player: $0, offersWedding: $0 == offerer) } + indexOfWeddingOffer = table.players.index(of: offerer) + super.init(table: table, players: players) + } + + var hasRemainingActors: Bool { + players.contains { $0.requiresAction } + } + + var requiresCardSelection: Bool { + players.contains { $0.selectsGame } + } + + override var playedGame: GameType? { + .hochzeit + } + + override func perform(action: PlayerAction, forPlayer name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { + guard let player = players.player(named: name) else { + print("Player \(name) unexpectedly missing from wedding table \(self.name)") + return (.tableStateInvalid, nil) + } + guard player.canPerform(action) else { + return (.tableStateInvalid, nil) + } + + switch action { + case .acceptWedding: + return performWeddingAccept(forPlayer: player) + case .withdrawFromAuction: + return performWithdrawl(forPlayer: player) + case .increaseOrMatchGame: + fatalError() + default: + return (.tableStateInvalid, nil) + } + } + + private func performWeddingAccept(forPlayer player: WeddingPlayer) -> (result: PlayerActionResult, table: ManageableTable?) { + guard player.requiresAction else { + return (.tableStateInvalid, nil) + } + player.state = .wouldAcceptWedding + guard !hasRemainingActors else { + return (.success, nil) + } + // Nobody wants to play a higher game, so let the first player accept the wedding + players.first { $0.wouldAcceptWedding }!.selectsGame = true + return (.success, nil) + } + + private func performWithdrawl(forPlayer player: WeddingPlayer) -> (result: PlayerActionResult, table: ManageableTable?) { + guard player.requiresAction else { + return (.tableStateInvalid, nil) + } + player.state = .withdrawnFromAuction + guard !hasRemainingActors else { + return (.success, nil) + } + // Nobody wants to play a higher game, so let the first player accept the wedding + guard let player = players.first(where: { $0.wouldAcceptWedding }) else { + // Nobody wants to accept the wedding or play something higher, so abort the game + let table = WaitingTable(oldTableAdvancedByOne: self) + return (.success, table) + } + player.selectsGame = true + return (.success, nil) + } + + private func performOutbid(forPlayer player: WeddingPlayer) -> (result: PlayerActionResult, table: ManageableTable?) { + guard player.requiresAction else { + return (.tableStateInvalid, nil) + } + let table = BiddingTable(wedding: self, outbidBy: player) + return (.success, table) + } + + override func playerData(at index: Int) -> (actions: [PlayerAction], games: [GameConvertible], cards: [PlayableCard], selectsGame: Bool) { + guard requiresCardSelection else { + return super.playerData(at: index) + } + let player = players[index] + guard player.selectsGame else { + return super.playerData(at: index) + } + return (actions: player.actions, games: [], cards: player.exchangeableCards, selectsGame: false) + } + + override func play(card: Card, player name: PlayerName) -> (result: PlayerActionResult, table: ManageableTable?) { + guard requiresCardSelection else { + print("Wedding is not in stage where card should be selected") + return (.tableStateInvalid, nil) + } + guard let player = players.player(named: name) else { + print("Player \(name) unexpectedly missing from wedding table \(self.name)") + return (.tableStateInvalid, nil) + } + guard player.selectsGame else { + print("Player \(name) is not the one selecting a wedding card") + return (.tableStateInvalid, nil) + } + guard player.canExchange(card: card) else { + print("Invalid card \(card) to exchange in wedding") + return (.tableStateInvalid, nil) + } + + let offerer = players[indexOfWeddingOffer] + let trumpCard = offerer.replaceWeddingCard(with: card) + player.replace(card, with: trumpCard) + let table = PlayingTable(wedding: self, offeredBy: offerer, acceptedBy: player) + return (.success, table) + } + +} diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 18d00de..2583773 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -6,7 +6,8 @@ var database: Database! public func configure(_ app: Application) throws { // Set target environment - app.environment = .development + app.environment = .production + app.logger.logLevel = .info // .notice // serve files from /Public folder app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))