diff --git a/Sources/App/Model/Crypto+Extensions.swift b/Sources/App/Extensions/Crypto+Extensions.swift similarity index 100% rename from Sources/App/Model/Crypto+Extensions.swift rename to Sources/App/Extensions/Crypto+Extensions.swift diff --git a/Sources/App/Management/ClientConnection.swift b/Sources/App/Management/ClientConnection.swift new file mode 100644 index 0000000..16fb6f6 --- /dev/null +++ b/Sources/App/Management/ClientConnection.swift @@ -0,0 +1,32 @@ +import Foundation +import WebSocketKit + +private let encoder = JSONEncoder() + +enum ClientMessageType: String { + + /// The names and connection states of th player, plus table name and id + case tableInfo = "t" + + /// The hand cards of the player and the cards on the table + case cardInfo = "c" + + /// The game is in the bidding phase + case biddingInfo = "b" + + /// +} + +protocol ClientMessage: Encodable { + + static var type: ClientMessageType { get } +} + +extension WebSocket { + + func send(_ data: T) where T: ClientMessage { + let json = try! encoder.encode(data) + let string = String(data: json, encoding: .utf8)! + self.send(T.type.rawValue + string) + } +} diff --git a/Sources/App/Model/Database.swift b/Sources/App/Management/Database.swift similarity index 90% rename from Sources/App/Model/Database.swift rename to Sources/App/Management/Database.swift index 1103a06..be9da6c 100644 --- a/Sources/App/Model/Database.swift +++ b/Sources/App/Management/Database.swift @@ -9,9 +9,7 @@ final class Database { init(storageFolder: URL) throws { self.players = try PlayerManagement(storageFolder: storageFolder) - self.tables = TableManagement() - // TODO: Load table data from disk - // TODO: Save data to disk + self.tables = try TableManagement(storageFolder: storageFolder) } // MARK: Players & Sessions @@ -49,10 +47,6 @@ final class Database { guard let player = players.endSession(forSessionToken: sessionToken) else { return } - closeSession(for: player) - } - - private func closeSession(for player: PlayerName) { tables.disconnect(player: player) } @@ -104,4 +98,11 @@ final class Database { tables.remove(player: player) return true } + + func dealCards(playerToken: SessionToken) -> DealCardResult { + guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else { + return .invalidToken + } + return tables.dealCards(player: player) + } } diff --git a/Sources/App/Management/DiskWriter.swift b/Sources/App/Management/DiskWriter.swift new file mode 100644 index 0000000..4a73676 --- /dev/null +++ b/Sources/App/Management/DiskWriter.swift @@ -0,0 +1,62 @@ +// +// File.swift +// +// +// Created by iMac on 01.12.21. +// + +import Foundation + + +protocol DiskWriter { + + var storageFile: FileHandle { get } + + var storageFileUrl: URL { get } +} + +extension DiskWriter { + + static func prepareFile(at url: URL) throws -> FileHandle { + if !FileManager.default.fileExists(atPath: url.path) { + try Data().write(to: url) + } + return try FileHandle(forUpdating: url) + } + + func writeToDisk(line: String) -> Bool { + let data = (line + "\n").data(using: .utf8)! + do { + if #available(macOS 10.15.4, *) { + try storageFile.write(contentsOf: data) + } else { + storageFile.write(data) + } + try storageFile.synchronize() + return true + } catch { + print("Failed to save data to file: \(storageFileUrl.path): \(error)") + return false + } + } + + func readLinesFromDisk() throws -> [String] { + if #available(macOS 10.15.4, *) { + guard let data = try storageFile.readToEnd() else { + try storageFile.seekToEnd() + return [] + } + return parseLines(data: data) + } else { + let data = storageFile.readDataToEndOfFile() + return parseLines(data: data) + } + } + + private func parseLines(data: Data) -> [String] { + String(data: data, encoding: .utf8)! + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { $0 != "" } + } +} diff --git a/Sources/App/Model/PlayerManagement.swift b/Sources/App/Management/PlayerManagement.swift similarity index 67% rename from Sources/App/Model/PlayerManagement.swift rename to Sources/App/Management/PlayerManagement.swift index 5bdf65a..fd623e1 100644 --- a/Sources/App/Model/PlayerManagement.swift +++ b/Sources/App/Management/PlayerManagement.swift @@ -5,7 +5,7 @@ typealias PasswordHash = String typealias SessionToken = String /// Manages player registration, session tokens and password hashes -final class PlayerManagement { +final class PlayerManagement: DiskWriter { /// A mapping between player name and their password hashes private var playerPasswordHashes = [PlayerName: PasswordHash]() @@ -16,74 +16,41 @@ final class PlayerManagement { /// A reverse mapping between generated access tokens and player name private var playerNameForToken = [SessionToken: PlayerName]() - private let passwordFile: FileHandle + let storageFile: FileHandle - private let passwordFileUrl: URL + let storageFileUrl: URL init(storageFolder: URL) throws { let url = storageFolder.appendingPathComponent("passwords.txt") - if !FileManager.default.fileExists(atPath: url.path) { - try Data().write(to: url) - } + storageFileUrl = url + storageFile = try Self.prepareFile(at: url) - passwordFile = try FileHandle(forUpdating: url) - passwordFileUrl = url - - if #available(macOS 10.15.4, *) { - guard let data = try passwordFile.readToEnd() else { - try passwordFile.seekToEnd() + try readLinesFromDisk().forEach { line in + let parts = line.components(separatedBy: ":") + // Token may contain the separator + guard parts.count >= 2 else { + print("Invalid line in password file") return } - try loadPasswords(data: data) - } else { - let data = passwordFile.readDataToEndOfFile() - try loadPasswords(data: data) + let name = parts[0] + let token = parts.dropFirst().joined(separator: ":") + if token == "" { + playerPasswordHashes[name] = nil + } else { + playerPasswordHashes[name] = token + } } + print("Loaded \(playerPasswordHashes.count) players") } - private func loadPasswords(data: Data) throws { - String(data: data, encoding: .utf8)! - .components(separatedBy: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { $0 != "" } - .forEach { line in - let parts = line.components(separatedBy: ":") - // Token may contain the separator - guard parts.count >= 2 else { - print("Invalid line in password file") - return - } - let name = parts[0] - let token = parts.dropFirst().joined(separator: ":") - if token == "" { - playerPasswordHashes[name] = nil - } else { - playerPasswordHashes[name] = token - } - } - } - private func save(password: PasswordHash, forPlayer player: PlayerName) -> Bool { - let entry = player + ":" + password + "\n" - let data = entry.data(using: .utf8)! - do { - if #available(macOS 10.15.4, *) { - try passwordFile.write(contentsOf: data) - } else { - passwordFile.write(data) - } - try passwordFile.synchronize() - return true - } catch { - print("Failed to save password to disk: \(error)") - return false - } + writeToDisk(line: player + ":" + password) } private func deletePassword(forPlayer player: PlayerName) -> Bool { - save(password: "", forPlayer: player) + writeToDisk(line: player + ":") } /** diff --git a/Sources/App/Management/TableManagement.swift b/Sources/App/Management/TableManagement.swift new file mode 100644 index 0000000..83a5de7 --- /dev/null +++ b/Sources/App/Management/TableManagement.swift @@ -0,0 +1,252 @@ +import Foundation +import WebSocketKit +import Vapor + +let maximumPlayersPerTable = 4 + +typealias TableId = String +typealias TableName = String + +final class TableManagement: DiskWriter { + + /// A list of table ids for public games + private var publicTables = Set() + + /// A mapping from table id to table name (for all tables) + private var tableNames = [TableId: TableName]() + + /// A mapping from table id to participating players + private var tablePlayers = [TableId: [PlayerName]]() + + /// A reverse list of players and their table id + private var playerTables = [PlayerName: TableId]() + + private var playerConnections = [PlayerName : WebSocket]() + + private var tablePhase = [TableId: GamePhase]() + + + let storageFile: FileHandle + + let storageFileUrl: URL + + init(storageFolder: URL) throws { + let url = storageFolder.appendingPathComponent("tables.txt") + + storageFileUrl = url + storageFile = try Self.prepareFile(at: url) + + var entries = [TableId : (name: TableName, public: Bool, players: [PlayerName])]() + try readLinesFromDisk().forEach { line in + // Each line has parts: ID | NAME | PLAYER, PLAYER, ... + let parts = line.components(separatedBy: ":") + guard parts.count == 4 else { + print("Invalid line in table file") + return + } + let id = parts[0] + let name = parts[1] + let isPublic = parts[2] == "public" + let players = parts[3].components(separatedBy: ",") + if name == "" { + entries[id] = nil + } else { + entries[id] = (name, isPublic, players) + } + } + entries.forEach { id, table in + tableNames[id] = table.name + if table.public { + publicTables.insert(id) + } + tablePlayers[id] = table.players + tablePhase[id] = .waitingForPlayers + for player in table.players { + playerTables[player] = id + } + } + print("Loaded \(tableNames.count) tables") + } + + @discardableResult + private func save(table tableId: TableId) -> Bool { + let name = tableNames[tableId]! + let visible = publicTables.contains(tableId) ? "public" : "private" + let players = tablePlayers[tableId]! + let entry = [tableId, name, visible, players.joined(separator: ",")].joined(separator: ":") + return writeToDisk(line: entry) + } + + @discardableResult + private func deleteTable(tableId: TableId) -> Bool { + let entry = [tableId, "", "", ""].joined(separator: ":") + return writeToDisk(line: entry) + } + + /** + Create a new table with optional players. + - Parameter name: The name of the table + - Parameter players: The player creating the table + - Parameter visible: Indicates that this is a game joinable by everyone + - Returns: The table id + */ + func createTable(named name: TableName, player: PlayerName, visible: Bool) -> TableId { + let tableId = TableId.newToken() + + tableNames[tableId] = name + tablePlayers[tableId] = [player] + playerTables[player] = tableId + + if visible { + publicTables.insert(tableId) + } + save(table: tableId) + return tableId + } + + func getPublicTableInfos() -> [TableInfo] { + publicTables.map(tableInfo).sorted() + } + + private func tableInfo(id tableId: TableId) -> TableInfo { + let players = tablePlayers[tableId]!.map(playerState) + return TableInfo( + id: tableId, + name: tableNames[tableId]!, + players: players, + tableIsFull: players.count == maximumPlayersPerTable) + } + + private func playerState(_ player: PlayerName) -> TableInfo.PlayerState { + .init(name: player, connected: playerIsConnected(player)) + } + + private func playerIsConnected(_ player: PlayerName) -> Bool { + playerConnections[player] != nil + } + + func currentTableOfPlayer(named player: PlayerName) -> TableId? { + playerTables[player] + } + + /** + Join a table. + - Returns: The result of the join operation + */ + func join(tableId: TableId, player: PlayerName) -> JoinTableResult { + guard var players = tablePlayers[tableId] else { + return .tableNotFound + } + guard !players.contains(player) else { + return .success + } + guard players.count < maximumPlayersPerTable else { + return .tableIsFull + } + players.append(player) + if let oldTable = playerTables[tableId] { + remove(player: player, fromTable: oldTable) + } + tablePlayers[tableId] = players + playerTables[player] = tableId + save(table: tableId) + return .success + } + + func remove(player: PlayerName, fromTable tableId: TableId) { + tablePlayers[tableId] = tablePlayers[tableId]?.filter { $0 != player } + disconnect(player: player) + playerTables[player] = nil + // TODO: End game if needed + // TODO: Remove table if empty + save(table: tableId) + } + + func remove(player: PlayerName) { + guard let tableId = playerTables[player] else { + return + } + // Already saves table to disk + remove(player: player, fromTable: tableId) + } + + func connect(player: PlayerName, using socket: WebSocket) -> Bool { + guard let tableId = playerTables[player] else { + return false + } + guard let players = tablePlayers[tableId] else { + print("Player \(player) was assigned to missing table \(tableId.prefix(5))") + playerTables[player] = nil + return false + } + guard players.contains(player) else { + print("Player \(player) wants updates for table \(tableId.prefix(5)) it didn't join") + return false + } + playerConnections[player] = socket + sendTableInfo(toTable: tableId) + // TODO: Send cards to player + return true + } + + func disconnect(player: PlayerName) { + if let socket = playerConnections.removeValue(forKey: player) { + if !socket.isClosed { + _ = socket.close() + } + } + guard let tableId = playerTables[player] else { + return + } + sendTableInfo(toTable: tableId) + // Change table phase to waiting + } + + private func sendTableInfo(toTable tableId: TableId) { + let name = tableNames[tableId]! + var players = tablePlayers[tableId]! + let isFull = players.count == maximumPlayersPerTable + for _ in players.count.. DealCardResult { + guard let tableId = playerTables[player] else { + return .noTableJoined + } + guard let players = tablePlayers[tableId] else { + playerTables[player] = nil + print("Player \(player) assigned to missing table \(tableId.prefix(5))") + return .noTableJoined + } + guard players.count == maximumPlayersPerTable else { + return .tableNotFull + } + + let cards = Dealer.deal() + let handCards = ["", "", "", ""] + players.enumerated().forEach { index, player in + guard let socket = playerConnections[player] else { + return + } + let info = CardInfo( + cards: cards[index].map { .init(card: $0.id, playable: false) }, + tableCards: handCards.rotated(toStartAt: index)) + socket.send(info) + } + return .success + } +} diff --git a/Sources/App/Infos/Card.swift b/Sources/App/Model/Card.swift similarity index 55% rename from Sources/App/Infos/Card.swift rename to Sources/App/Model/Card.swift index 96ed063..a5553ce 100644 --- a/Sources/App/Infos/Card.swift +++ b/Sources/App/Model/Card.swift @@ -1,10 +1,11 @@ import Foundation +import Vapor typealias CardId = String -struct Card { +struct Card: Codable { - enum Symbol: Character { + enum Symbol: Character, CaseIterable, Codable { case ass = "A" case zehn = "Z" case könig = "K" @@ -13,16 +14,32 @@ struct Card { case neun = "9" case acht = "8" case sieben = "7" + + var points: Int { + switch self { + case .ass: + return 11 + case .zehn: + return 10 + case .könig: + return 4 + case .ober: + return 3 + case .unter: + return 2 + default: + return 0 + } + } } - enum Suit: Character { + enum Suit: Character, CaseIterable, Codable { case eichel = "E" case blatt = "B" case herz = "H" case schelln = "S" } - let symbol: Symbol let suit: Suit @@ -32,6 +49,11 @@ struct Card { self.symbol = symbol } + init(_ suit: Suit, _ symbol: Symbol) { + self.suit = suit + self.symbol = symbol + } + init?(rawValue: String) { guard rawValue.count == 2 else { return nil @@ -45,14 +67,22 @@ struct Card { } var id: CardId { - "\(suit)\(symbol)" + "\(suit.rawValue)\(symbol.rawValue)" + } + + var points: Int { + symbol.points } } extension Card: CustomStringConvertible { var description: String { - id + "\(suit) \(symbol)" } } +extension Card: Hashable { + +} + diff --git a/Sources/App/Model/ClientConnection.swift b/Sources/App/Model/ClientConnection.swift deleted file mode 100644 index 2e438fe..0000000 --- a/Sources/App/Model/ClientConnection.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation -import WebSocketKit - -private let encoder = JSONEncoder() - -enum ClientMessageType: String { - - case tableInfo = "t" -} - -extension WebSocket { - - func send(_ type: ClientMessageType, data: T) where T: Encodable { - let json = try! encoder.encode(data) - let string = String(data: json, encoding: .utf8)! - self.send(type.rawValue + string) - } -} diff --git a/Sources/App/Model/TableManagement.swift b/Sources/App/Model/TableManagement.swift deleted file mode 100644 index 0bbc40e..0000000 --- a/Sources/App/Model/TableManagement.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation -import WebSocketKit -import Vapor - -let maximumPlayersPerTable = 4 - -typealias TableId = String -typealias TableName = String - -final class TableManagement { - - /// A list of table ids for public games - private var publicTables = Set() - - /// A mapping from table id to table name (for all tables) - private var tableNames = [TableId: TableName]() - - /// A mapping from table id to participating players - private var tablePlayers = [TableId: [PlayerName]]() - - /// A reverse list of players and their table id - private var playerTables = [PlayerName: TableId]() - - private var playerConnections = [PlayerName : WebSocket]() - - init() { - - } - - /** - Create a new table with optional players. - - Parameter name: The name of the table - - Parameter players: The player creating the table - - Parameter visible: Indicates that this is a game joinable by everyone - - Returns: The table id - */ - func createTable(named name: TableName, player: PlayerName, visible: Bool) -> TableId { - let tableId = TableId.newToken() - - tableNames[tableId] = name - tablePlayers[tableId] = [player] - playerTables[player] = tableId - - if visible { - publicTables.insert(tableId) - } - return tableId - } - - func getPublicTableInfos() -> [TableInfo] { - publicTables.map(tableInfo).sorted() - } - - private func tableInfo(id tableId: TableId) -> TableInfo { - let players = tablePlayers[tableId]! - let connected = players.map { playerConnections[$0] != nil } - return TableInfo( - id: tableId, - name: tableNames[tableId]!, - players: players, - connected: connected) - } - - func currentTableOfPlayer(named player: PlayerName) -> TableId? { - playerTables[player] - } - - /** - Join a table. - - Returns: The result of the join operation - */ - func join(tableId: TableId, player: PlayerName) -> JoinTableResult { - guard var players = tablePlayers[tableId] else { - return .tableNotFound - } - guard !players.contains(player) else { - return .success - } - guard players.count < maximumPlayersPerTable else { - return .tableIsFull - } - players.append(player) - if let oldTable = playerTables[tableId] { - remove(player: player, fromTable: oldTable) - } - tablePlayers[tableId] = players - playerTables[tableId] = tableId - return .success - } - - func remove(player: PlayerName, fromTable tableId: TableId) { - tablePlayers[tableId] = tablePlayers[tableId]?.filter { $0 != player } - // TODO: End connection for removed user - // TODO: End game if needed, send info to remaining players - } - - func remove(player: PlayerName) { - guard let tableId = playerTables[player] else { - return - } - remove(player: player, fromTable: tableId) - } - - func connect(player: PlayerName, using socket: WebSocket) -> Bool { - guard let tableId = playerTables[player] else { - return false - } - guard let players = tablePlayers[tableId] else { - print("Player \(player) was assigned to missing table \(tableId.prefix(5))") - playerTables[player] = nil - return false - } - guard players.contains(player) else { - print("Player \(player) was assigned to table \(tableId.prefix(5)) where it wasn't listed") - return false - } - playerConnections[player] = socket - - let tableInfo = self.tableInfo(id: tableId) - // Notify other players at table about changes - players - .compactMap { playerConnections[$0] } - .forEach { $0.send(.tableInfo, data: tableInfo) } - return true - } - - func disconnect(player: PlayerName) { - fatalError() - } -}