From 5eafcfdf4dc480ed3b6e5cf23e7c819301a81345 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Wed, 22 Dec 2021 22:13:09 +0100 Subject: [PATCH] Switch to SQLite database over text files --- Sources/App/Management/Database.swift | 122 ------------ Sources/App/Management/DiskWriter.swift | 78 -------- Sources/App/Management/PlayerManagement.swift | 164 ----------------- Sources/App/Management/TableManagement.swift | 173 ++++++------------ Sources/App/Model/Players/Player.swift | 9 +- Sources/App/Model/Tables/AbstractTable.swift | 12 +- .../App/Model/Tables/ManageableTable.swift | 4 +- Sources/App/Model/Tables/WaitingTable.swift | 21 ++- Sources/App/configure.swift | 26 ++- Sources/App/routes.swift | 103 ++++------- 10 files changed, 148 insertions(+), 564 deletions(-) delete mode 100644 Sources/App/Management/Database.swift delete mode 100644 Sources/App/Management/DiskWriter.swift delete mode 100644 Sources/App/Management/PlayerManagement.swift diff --git a/Sources/App/Management/Database.swift b/Sources/App/Management/Database.swift deleted file mode 100644 index ac03e9a..0000000 --- a/Sources/App/Management/Database.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Foundation -import Vapor - -final class Database { - - private let players: PlayerManagement - - private let tables: TableManagement - - init(storageFolder: URL) throws { - self.players = try PlayerManagement(storageFolder: storageFolder) - self.tables = try TableManagement(storageFolder: storageFolder) - } - - // MARK: Players & Sessions - - func registerPlayer(named name: PlayerName, hash: PasswordHash) -> SessionToken? { - players.registerPlayer(named: name, hash: hash) - } - - func passwordHashForExistingPlayer(named name: PlayerName) -> PasswordHash? { - players.passwordHash(ofRegisteredPlayer: name) - } - - func deletePlayer(named name: PlayerName) { - _ = players.deletePlayer(named: name) - tables.leaveTable(player: name) - } - - func isValid(sessionToken token: SessionToken) -> Bool { - players.isValid(sessionToken: token) - } - - func startSession(socket: WebSocket, sessionToken: SessionToken) -> Bool { - guard let player = players.registeredPlayerExists(withSessionToken: sessionToken) else { - return false - } - return tables.connect(player: player, using: socket) - } - - private func didReceive(message: String, forSessionToken token: SessionToken) { - // TODO: Handle client requests - print("Session \(token.prefix(6)): \(message)") - } - - func endSession(forSessionToken sessionToken: SessionToken) { - guard let player = players.endSession(forSessionToken: sessionToken) else { - return - } - tables.disconnect(player: player) - } - - /** - Start a new session for an existing user. - - Parameter name: The user name - - Returns: The generated access token for the session - */ - func startNewSessionForRegisteredPlayer(named name: PlayerName) -> SessionToken { - players.startNewSessionForRegisteredPlayer(named: name) - } - - func registeredPlayerExists(withSessionToken token: SessionToken) -> PlayerName? { - players.registeredPlayerExists(withSessionToken: token) - } - - func currentTableOfPlayer(named player: PlayerName) -> TableInfo? { - tables.tableInfo(player: player) - } - - // MARK: Tables - - /** - Create a new table with optional players. - - Parameter name: The name of the table - - Parameter players: The player creating the table - - Parameter isPublic: Indicates that this is a game joinable by everyone - - Returns: The table id - */ - func createTable(named name: TableName, player: PlayerName, isPublic: Bool) -> TableInfo { - tables.createTable(named: name, player: player, isPublic: isPublic) - } - - func getPublicTableInfos() -> [PublicTableInfo] { - tables.publicTableList - } - - func join(tableId: TableId, playerToken: SessionToken) -> Result { - guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else { - return .failure(.invalidToken) - } - return tables.join(tableId: tableId, player: player) - } - - func leaveTable(playerToken: SessionToken) -> Bool { - guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else { - return false - } - tables.leaveTable(player: player) - return true - } - - func performAction(playerToken: SessionToken, action: PlayerAction) -> PlayerActionResult { - guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else { - return .invalidToken - } - return tables.performAction(player: player, action: action) - } - - func select(game: GameType, playerToken: SessionToken) -> PlayerActionResult { - guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else { - return .invalidToken - } - return tables.select(game: game, player: player) - } - - func play(card: Card, playerToken: SessionToken) -> PlayerActionResult { - guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else { - return .invalidToken - } - return tables.play(card: card, player: player) - } -} diff --git a/Sources/App/Management/DiskWriter.swift b/Sources/App/Management/DiskWriter.swift deleted file mode 100644 index 0425fca..0000000 --- a/Sources/App/Management/DiskWriter.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// File.swift -// -// -// Created by iMac on 01.12.21. -// - -import Foundation - - -protocol DiskWriter: AnyObject { - - var storageFile: FileHandle { get set } - - var storageFileUrl: URL { get } -} - -extension DiskWriter { - - func replaceFile(data: String) throws { - let data = data.data(using: .utf8)! - try storageFile.close() - try data.write(to: storageFileUrl) - storageFile = try FileHandle(forUpdating: storageFileUrl) - if #available(macOS 10.15.4, *) { - try storageFile.seekToEnd() - } else { - storageFile.seekToEndOfFile() - } - } - - 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 readDataFromDisk() throws -> Data { - if #available(macOS 10.15.4, *) { - guard let data = try storageFile.readToEnd() else { - try storageFile.seekToEnd() - return Data() - } - return data - } else { - return storageFile.readDataToEndOfFile() - } - } - - func readLinesFromDisk() throws -> [String] { - let data = try readDataFromDisk() - 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/Management/PlayerManagement.swift b/Sources/App/Management/PlayerManagement.swift deleted file mode 100644 index a1252ca..0000000 --- a/Sources/App/Management/PlayerManagement.swift +++ /dev/null @@ -1,164 +0,0 @@ -import Foundation - -typealias PlayerName = String -typealias PasswordHash = String -typealias SessionToken = String - -/// Manages player registration, session tokens and password hashes -final class PlayerManagement: DiskWriter { - - /// A mapping between player name and their password hashes - private var playerPasswordHashes = [PlayerName: PasswordHash]() - - /// A mapping between player name and generated access tokens for a session - private var sessionTokenForPlayer = [PlayerName: SessionToken]() - - /// A reverse mapping between generated access tokens and player name - private var playerNameForToken = [SessionToken: PlayerName]() - - var storageFile: FileHandle - - let storageFileUrl: URL - - init(storageFolder: URL) throws { - let url = storageFolder.appendingPathComponent("passwords.txt") - - storageFileUrl = url - storageFile = try Self.prepareFile(at: url) - - var redundantEntries = 0 - 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 - } - let name = parts[0] - let token = parts.dropFirst().joined(separator: ":") - if token == "" { - playerPasswordHashes[name] = nil - redundantEntries += 2 // One for creation, one for deletion - return - } - if playerPasswordHashes[name] != nil { - redundantEntries += 1 - } - playerPasswordHashes[name] = token - } - - let playerCount = playerPasswordHashes.count - let totalEntries = playerCount + redundantEntries - let percentage = playerCount * 100 / totalEntries - print("Loaded \(playerCount) players from \(totalEntries) entries (\(percentage) % useful)") - if percentage < 80 && redundantEntries > 10 { - try optimizePlayerFile() - } - } - - private func optimizePlayerFile() throws { - print("Optimizing player file...") - let lines = playerPasswordHashes.map { $0.key + ":" + $0.value + "\n" }.joined() - try replaceFile(data: lines) - print("Done.") - } - - private func save(password: PasswordHash, forPlayer player: PlayerName) -> Bool { - writeToDisk(line: player + ":" + password) - } - - private func deletePassword(forPlayer player: PlayerName) -> Bool { - writeToDisk(line: player + ":") - } - - /** - Check if a player exists. - - Parameter name: The name of the player - - Returns: true, if the player exists - */ - func hasRegisteredPlayer(named user: PlayerName) -> Bool { - playerPasswordHashes[user] != nil - } - - /** - Get the password hash for a player, if the player exists. - - Parameter name: The name of the player - - Returns: The stored password hash, if the player exists - */ - func passwordHash(ofRegisteredPlayer name: PlayerName) -> PasswordHash? { - playerPasswordHashes[name] - } - - /** - Create a new player and assign an access token. - - Parameter name: The name of the new player - - Parameter hash: The password hash of the player - - Returns: The generated access token for the session - */ - func registerPlayer(named name: PlayerName, hash: PasswordHash) -> SessionToken? { - guard !hasRegisteredPlayer(named: name) else { - return nil - } - guard save(password: hash, forPlayer: name) else { - return nil - } - self.playerPasswordHashes[name] = hash - return startNewSessionForRegisteredPlayer(named: name) - } - - /** - Delete a player - - Parameter name: The name of the player to delete. - - Returns: The session token of the current player, if one exists - */ - func deletePlayer(named name: PlayerName) -> SessionToken? { - guard deletePassword(forPlayer: name) else { - return nil - } - playerPasswordHashes.removeValue(forKey: name) - guard let sessionToken = sessionTokenForPlayer.removeValue(forKey: name) else { - return nil - } - playerNameForToken.removeValue(forKey: sessionToken) - return sessionToken - } - - func isValid(sessionToken token: SessionToken) -> Bool { - playerNameForToken[token] != nil - } - - func sessionToken(forPlayer player: PlayerName) -> SessionToken? { - sessionTokenForPlayer[player] - } - - /** - Start a new session for an existing player. - - Parameter name: The player name - - Returns: The generated access token for the session - */ - func startNewSessionForRegisteredPlayer(named name: PlayerName) -> SessionToken { - let token = SessionToken.newToken() - self.sessionTokenForPlayer[name] = token - self.playerNameForToken[token] = name - return token - } - - func endSession(forSessionToken token: SessionToken) -> PlayerName? { - guard let player = playerNameForToken.removeValue(forKey: token) else { - return nil - } - sessionTokenForPlayer.removeValue(forKey: player) - return player - } - - /** - Get the player for a session token. - - Parameter token: The access token for the player - - Returns: The name of the player, if it exists - */ - func registeredPlayerExists(withSessionToken token: SessionToken) -> PlayerName? { - playerNameForToken[token] - } - - -} diff --git a/Sources/App/Management/TableManagement.swift b/Sources/App/Management/TableManagement.swift index 07c3522..f14266b 100644 --- a/Sources/App/Management/TableManagement.swift +++ b/Sources/App/Management/TableManagement.swift @@ -1,109 +1,36 @@ import Foundation import WebSocketKit import Vapor +import Fluent let maximumPlayersPerTable = 4 typealias TableId = String typealias TableName = String -final class TableManagement: DiskWriter { +final class TableManagement { /// All tables indexed by their id - private var tables = [TableId : ManageableTable]() - - /// The handle to the file where the tables are persisted - var storageFile: FileHandle - - /// The url to the file where the tables are persisted - let storageFileUrl: URL + private var tables = [UUID : ManageableTable]() /** Load the tables from a file in the storage folder - - Parameter storageFolder: The url to the folder where the table file is stored - Throws: Errors when the file could not be read */ - init(storageFolder: URL) throws { - let url = storageFolder.appendingPathComponent("tables.txt") - - storageFileUrl = url - storageFile = try Self.prepareFile(at: url) - - var entries = [TableId : (name: TableName, isPublic: Bool, players: [PlayerName])]() - var redundantEntries = 0 - 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 + init(db: Database) throws { + Table.query(on: db).with(\.$players).all().whenSuccess { loadedTables in + for table in loadedTables { + guard !table.players.isEmpty else { + _ = table.delete(on: db) + continue + } + let id = table.id! + self.tables[id] = WaitingTable(id: id, name: table.name, isPublic: table.isPublic, players: table.players) } - let id = parts[0] - let name = parts[1] - let isPublic = parts[2] == "public" - let players = parts[3].components(separatedBy: ",") - if name == "" { - entries[id] = nil - redundantEntries += 2 // One for creation, one for deletion - return - } - if entries[id] != nil { - redundantEntries += 1 - } - entries[id] = (name, isPublic, players) - } - entries.forEach { id, tableData in - tables[id] = WaitingTable(id: id, name: tableData.name, isPublic: tableData.isPublic, players: tableData.players) - } - let totalEntries = entries.count + redundantEntries - let percentage = entries.count * 100 / totalEntries - print("Loaded \(tables.count) tables from \(totalEntries) entries (\(percentage) % useful)") - if percentage < 80 && redundantEntries > 10 { - try optimizeTableFile() + print("\(self.tables.count) tables loaded") } } - private func optimizeTableFile() throws { - print("Optimizing tables file...") - let lines = tables.values.map(entry).joined(separator: "\n") + "\n" - try replaceFile(data: lines) - print("Done.") - } - - private func entry(for table: ManageableTable) -> String { - let visible = table.isPublic ? "public" : "private" - let players = table.playerNames - .joined(separator: ",") - return [table.id, table.name, visible, players].joined(separator: ":") - } - - /** - Writes the table info to disk. - - Currently only the id, name, visibility and players are stored, all other information is lost. - - Parameter table: The changed table information to persist - - Returns: `true`, if the entry was written, `false` on error - */ - @discardableResult - private func writeTableToDisk(table: ManageableTable) -> Bool { - let entry = entry(for: table) - return writeToDisk(line: entry) - } - - /** - Writes the deletion of a table to disk. - - The deletion is written as a separate entry and appended to the file, in order to reduce disk I/O. - - Parameter tableId: The id of the deleted table - - Returns: `true`, if the entry was written, `false` on error - */ - @discardableResult - private func writeTableDeletionEntry(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 @@ -111,11 +38,19 @@ final class TableManagement: DiskWriter { - Parameter isPublic: Indicates that this is a game joinable by everyone - Returns: The table id */ - func createTable(named name: TableName, player: PlayerName, isPublic: Bool) -> TableInfo { - let table = WaitingTable(newTable: name, isPublic: isPublic, creator: player) - tables[table.id] = table - writeTableToDisk(table: table) - return table.tableInfo(forPlayer: player) + func createTable(named name: TableName, player: User, isPublic: Bool, in database: Database) -> EventLoopFuture { + let table = Table(name: name, isPublic: isPublic) + return table.create(on: database).flatMap { + player.$table.id = table.id + return player.update(on: database) + }.flatMap { + Table.query(on: database).with(\.$players).filter(\.$id == table.id!).first() + }.unwrap(or: Abort(.notFound)) + .map { storedTable in + let table = WaitingTable(newTable: storedTable) + self.tables[table.id] = table + return table.tableInfo(forPlayer: player.name) + } } /// A list of all public tables @@ -142,40 +77,46 @@ final class TableManagement: DiskWriter { - Parameter player: The name of the player who wants to join. - Returns: The result of the join operation */ - func join(tableId: TableId, player: PlayerName) -> Result { - if let existing = currentTable(for: player) { - guard existing.id == tableId else { - return .failure(.alreadyJoinedOtherTable) + func join(tableId: UUID, player: User, in database: Database) -> EventLoopFuture { + return database.eventLoop.future().flatMapThrowing { _ -> ManageableTable in + if let existing = self.currentTable(for: player.name) { + guard existing.id == tableId else { + throw Abort(.forbidden) // 403 + } + return existing } - return .success(existing.tableInfo(forPlayer: player)) + guard let table = self.tables[tableId] else { + throw Abort(.gone) // 410 + } + guard let joinableTable = table as? WaitingTable, + joinableTable.add(player: player.name, points: player.points) else { + throw Abort(.expectationFailed) // 417 + } + return joinableTable + }.flatMap { table -> EventLoopFuture in + player.$table.id = table.id + return player.update(on: database).map { table } + }.map { table in + table.sendUpdateToAllPlayers() + return table.tableInfo(forPlayer: player.name) } - guard let table = tables[tableId] else { - return .failure(.tableNotFound) - } - guard let joinableTable = table as? WaitingTable else { - return .failure(.tableIsFull) - } - guard joinableTable.add(player: player) else { - return .failure(.tableIsFull) - } - writeTableToDisk(table: table) - joinableTable.sendUpdateToAllPlayers() - return .success(joinableTable.tableInfo(forPlayer: player)) } /** A player leaves the table it previously joined - - Parameter player: The name of the player + - Parameter player: The player leaving the table */ - func leaveTable(player: PlayerName) { - guard let oldTable = currentTable(for: player) else { - return + func leaveTable(player: User, in database: Database) -> EventLoopFuture { + guard let oldTable = currentTable(for: player.name) else { + return database.eventLoop.makeSucceededVoidFuture() } /// `player.canStartGame` is automatically set to false, because table is not full - let table = WaitingTable(oldTable: oldTable, removing: player) + let table = WaitingTable(oldTable: oldTable, removing: player.name) tables[table.id] = table table.sendUpdateToAllPlayers() - writeTableToDisk(table: table) + player.$table.id = nil + // TODO: Update points for all players + return player.update(on: database) } func connect(player: PlayerName, using socket: WebSocket) -> Bool { @@ -208,7 +149,7 @@ final class TableManagement: DiskWriter { tables[newTable.id] = newTable newTable.sendUpdateToAllPlayers() if newTable is FinishedTable || newTable is DealingTable { - writeTableToDisk(table: newTable) + // TODO: Save new table } return .success } @@ -232,6 +173,7 @@ final class TableManagement: DiskWriter { } tables[newTable.id] = newTable newTable.sendUpdateToAllPlayers() + // TODO: Save new table return .success } @@ -249,6 +191,7 @@ final class TableManagement: DiskWriter { } tables[newTable.id] = newTable newTable.sendUpdateToAllPlayers() + // TODO: Save new table return .success } } diff --git a/Sources/App/Model/Players/Player.swift b/Sources/App/Model/Players/Player.swift index 8cc4762..1b621c3 100644 --- a/Sources/App/Model/Players/Player.swift +++ b/Sources/App/Model/Players/Player.swift @@ -1,24 +1,30 @@ import Foundation import WebSocketKit +typealias PlayerName = String + class Player { let name: PlayerName + let totalPoints: Int + var socket: WebSocket? var isNextActor: Bool - init(name: PlayerName, socket: WebSocket? = nil) { + init(name: PlayerName, points: Int, socket: WebSocket? = nil) { self.name = name self.socket = socket self.isNextActor = false + self.totalPoints = points } init(player: Player) { self.name = player.name self.socket = player.socket self.isNextActor = false + self.totalPoints = player.totalPoints } var actions: [PlayerAction] { @@ -31,6 +37,7 @@ class Player { var info: PlayerInfo { var result = PlayerInfo(name: name) + result.points = totalPoints result.isConnected = isConnected result.isNextActor = isNextActor result.state = states.map { $0.rawValue } diff --git a/Sources/App/Model/Tables/AbstractTable.swift b/Sources/App/Model/Tables/AbstractTable.swift index 414a6ef..fe3ce77 100644 --- a/Sources/App/Model/Tables/AbstractTable.swift +++ b/Sources/App/Model/Tables/AbstractTable.swift @@ -7,7 +7,7 @@ let numberOfCardsPerPlayer = 8 class AbstractTable where TablePlayer: Player { /// The unique id of the table - let id: TableId + let id: UUID /// The name of the table let name: TableName @@ -28,6 +28,10 @@ class AbstractTable where TablePlayer: Player { nil } + var leavePenalty: Int { + 5 + } + init(table: ManageableTable, players: [TablePlayer]) { self.id = table.id self.name = table.name @@ -35,7 +39,7 @@ class AbstractTable where TablePlayer: Player { self.players = players } - init(id: TableId, name: TableName, isPublic: Bool, players: [TablePlayer]) { + init(id: UUID, name: TableName, isPublic: Bool, players: [TablePlayer]) { self.id = id self.name = name self.isPublic = isPublic @@ -67,7 +71,7 @@ class AbstractTable where TablePlayer: Player { } func tableInfo(forPlayerAt index: Int) -> TableInfo { - var info = TableInfo(id: id, name: name) + var info = TableInfo(id: id.uuidString, name: name) info.player = playerInfo(forIndex: index)! info.playerLeft = playerInfo(forIndex: (index + 1) % 4) info.playerAcross = playerInfo(forIndex: (index + 2) % 4) @@ -85,7 +89,7 @@ class AbstractTable where TablePlayer: Player { extension AbstractTable: ManageableTable { var publicInfo: PublicTableInfo { - .init(id: id, name: name, players: playerNames) + .init(id: id.uuidString, name: name, players: playerNames) } var playerNames: [PlayerName] { diff --git a/Sources/App/Model/Tables/ManageableTable.swift b/Sources/App/Model/Tables/ManageableTable.swift index f3656ce..f1bb1e1 100644 --- a/Sources/App/Model/Tables/ManageableTable.swift +++ b/Sources/App/Model/Tables/ManageableTable.swift @@ -4,7 +4,7 @@ import WebSocketKit protocol ManageableTable { /// The unique id of the table - var id: TableId { get } + var id: UUID { get } /// The name of the table var name: TableName { get } @@ -12,6 +12,8 @@ protocol ManageableTable { /// The table is visible in the list of tables and can be joined by anyone var isPublic: Bool { get } + var leavePenalty: Int { get } + var playerNames: [PlayerName] { get } var allPlayers: [Player] { get } diff --git a/Sources/App/Model/Tables/WaitingTable.swift b/Sources/App/Model/Tables/WaitingTable.swift index 04228ad..5d8ec4e 100644 --- a/Sources/App/Model/Tables/WaitingTable.swift +++ b/Sources/App/Model/Tables/WaitingTable.swift @@ -1,4 +1,5 @@ import Foundation +import Fluent /** Represents a table where players are still joining and leaving. @@ -10,8 +11,8 @@ final class WaitingTable: AbstractTable { players.count >= maximumPlayersPerTable } - init(id: TableId, name: TableName, isPublic: Bool, players: [PlayerName]) { - let players = players.map { WaitingPlayer(name: $0) } + init(id: UUID, name: TableName, isPublic: Bool, players: [User]) { + let players = players.map { WaitingPlayer(name: $0.name, points: $0.points) } players.first!.isNextActor = true super.init(id: id, name: name, isPublic: isPublic, players: players) if isFull { @@ -24,10 +25,11 @@ 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, creator: PlayerName) { - let player = WaitingPlayer(name: creator) + init(newTable object: Table) { + let user = object.players[0] + let player = WaitingPlayer(name: user.name, points: user.points) player.isNextActor = true - super.init(id: .newToken(), name: name, isPublic: isPublic, players: [player]) + super.init(id: object.id!, name: object.name, isPublic: object.isPublic, players: [player]) } /** @@ -38,6 +40,7 @@ final class WaitingTable: AbstractTable { - Parameter player: The name of the player to remove from the table. */ init(oldTable: ManageableTable, removing player: PlayerName) { + // TODO: End game and distribute points let players = oldTable.allPlayers .filter { guard $0.name == player else { @@ -46,7 +49,7 @@ final class WaitingTable: AbstractTable { _ = $0.disconnect() return false } - .map { WaitingPlayer(name: $0.name, socket: $0.socket) } + .map { WaitingPlayer(name: $0.name, points: $0.totalPoints, socket: $0.socket) } players.first!.isNextActor = true super.init(table: oldTable, players: players) } @@ -60,7 +63,7 @@ final class WaitingTable: AbstractTable { init(oldTableAdvancedByOne table: ManageableTable) { let players = table.allPlayers .rotatedByOne() - .map { WaitingPlayer(name: $0.name, socket: $0.socket) } + .map(WaitingPlayer.init) super.init(table: table, players: players) players.forEach { $0.canStartGame = true } players.first!.isNextActor = true @@ -71,11 +74,11 @@ final class WaitingTable: AbstractTable { - Parameter player: The name of the player to add - Returns: `true`, if the player could be added, `false` if the table is full */ - func add(player: PlayerName) -> Bool { + func add(player: PlayerName, points: Int) -> Bool { guard !isFull else { return false } - let player = WaitingPlayer(name: player) + let player = WaitingPlayer(name: player, points: points) players.append(player) // Allow dealing of cards if table is full if isFull { diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 2583773..4f35ac9 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -1,20 +1,36 @@ import Vapor +import Fluent -var database: Database! +var server: SQLiteDatabase! // configures your application public func configure(_ app: Application) throws { + let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory) // Set target environment app.environment = .production - app.logger.logLevel = .info // .notice + + if app.environment == .development { + app.logger.logLevel = .info + print("[DEVELOPMENT] Using in-memory database") + app.databases.use(.sqlite(.memory), as: .sqlite) + + } else { + app.logger.logLevel = .notice + let dbFile = storageFolder.appendingPathComponent("db.sqlite").path + print("[PRODUCTION] Using database at \(dbFile)") + app.databases.use(.sqlite(.file(dbFile)), as: .sqlite) + } + app.migrations.add(UserTableMigration()) + + try app.autoMigrate().wait() // serve files from /Public folder app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) - let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory) - database = try Database(storageFolder: storageFolder) - + let db = app.databases.database(.sqlite, logger: .init(label: "Init"), on: app.databases.eventLoopGroup.next())! + server = try SQLiteDatabase(db: db) + // register routes try routes(app) } diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 2df3319..b40fe72 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -29,7 +29,7 @@ func routes(_ app: Application) throws { - 424: The password could not be hashed - Returns: The session token for the registered user */ - app.post("player", "register", ":name") { req -> String in + app.post("player", "register", ":name") { req -> EventLoopFuture in guard let name = req.parameters.get("name"), let password = req.body.string else { throw Abort(.badRequest) // 400 @@ -42,10 +42,8 @@ func routes(_ app: Application) throws { guard let hash = try? req.password.hash(password) else { throw Abort(.failedDependency) // 424 } - guard let token = database.registerPlayer(named: name, hash: hash) else { - throw Abort(.conflict) // 409 - } - return token + // Can throw conflict (409) + return server.registerPlayer(named: name, hash: hash, in: req.db) } /** @@ -58,22 +56,17 @@ func routes(_ app: Application) throws { - 424: The password could not be hashed - Returns: Nothing */ - app.post("player", "delete", ":name") { req -> String in + app.post("player", "delete", ":name") { req -> EventLoopFuture in guard let name = req.parameters.get("name"), let password = req.body.string else { throw Abort(.badRequest) // 400 } - guard let hash = database.passwordHashForExistingPlayer(named: name) else { - throw Abort(.forbidden) // 403 - } - guard let isValid = try? req.password.verify(password, created: hash) else { - throw Abort(.failedDependency) // 424 - } - guard isValid else { - throw Abort(.forbidden) // 403 - } - database.deletePlayer(named: name) - return "" + return server.passwordHashForExistingPlayer(named: name, in: req.db) + .guard({ hash in + (try? req.password.verify(password, created: hash)) ?? false + }, else: Abort(.forbidden)).flatMap { _ in + server.deletePlayer(named: name, in: req.db) + }.map { "" } } /** @@ -86,22 +79,17 @@ func routes(_ app: Application) throws { - 424: The password could not be hashed - Returns: The session token for the user */ - app.post("player", "login", ":name") { req -> String in + app.post("player", "login", ":name") { req -> EventLoopFuture in guard let name = req.parameters.get("name"), let password = req.body.string else { throw Abort(.badRequest) // 400 } - guard let hash = database.passwordHashForExistingPlayer(named: name) else { - throw Abort(.forbidden) // 403 - } - guard let isValid = try? req.password.verify(password, created: hash) else { - throw Abort(.failedDependency) // 424 - } - guard isValid else { - throw Abort(.forbidden) // 403 - } - let token = database.startNewSessionForRegisteredPlayer(named: name) - return token + return server.passwordHashForExistingPlayer(named: name, in: req.db) + .guard({ hash in + (try? req.password.verify(password, created: hash)) ?? false + }, else: Abort(.forbidden)).map { _ in + server.startNewSessionForRegisteredPlayer(named: name) + } } /** @@ -116,7 +104,7 @@ func routes(_ app: Application) throws { guard let token = req.body.string else { throw Abort(.badRequest) // 400 } - guard let player = database.registeredPlayerExists(withSessionToken: token) else { + guard let player = server.registeredPlayerExists(withSessionToken: token) else { throw Abort(.unauthorized) // 401 } return player @@ -135,7 +123,7 @@ func routes(_ app: Application) throws { guard let token = req.body.string else { throw Abort(.badRequest) // 400 } - database.endSession(forSessionToken: token) + server.endSession(forSessionToken: token) return "" } @@ -151,10 +139,10 @@ func routes(_ app: Application) throws { guard let token = req.body.string else { throw Abort(.badRequest) // 400 } - guard let player = database.registeredPlayerExists(withSessionToken: token) else { + guard let player = server.registeredPlayerExists(withSessionToken: token) else { throw Abort(.unauthorized) // 401 } - guard let info = database.currentTableOfPlayer(named: player) else { + guard let info = server.currentTableOfPlayer(named: player) else { return "" } return try encodeJSON(info) @@ -167,7 +155,7 @@ func routes(_ app: Application) throws { */ app.webSocket("session", "start") { req, socket in socket.onText { socket, text in - guard database.startSession(socket: socket, sessionToken: text) else { + guard server.startSession(socket: socket, sessionToken: text) else { _ = socket.close() return } @@ -185,7 +173,7 @@ func routes(_ app: Application) throws { - 400: Missing token, table name or invalid visibility - 401: The session token is invalid */ - app.post("table", "create", ":visibility", ":name") { req -> String in + app.post("table", "create", ":visibility", ":name") { req -> EventLoopFuture in guard let visibility = req.parameters.get("visibility"), let tableName = req.parameters.get("name"), let token = req.body.string else { @@ -200,11 +188,11 @@ func routes(_ app: Application) throws { throw Abort(.badRequest) // 400 } - guard let player = database.registeredPlayerExists(withSessionToken: token) else { + guard let player = server.registeredPlayerExists(withSessionToken: token) else { throw Abort(.unauthorized) // 401 } - let table = database.createTable(named: tableName, player: player, isPublic: isPublic) - return try encodeJSON(table) + return server.createTable(named: tableName, player: player, isPublic: isPublic, in: req.db) + .flatMapThrowing(encodeJSON) } /** @@ -219,10 +207,10 @@ func routes(_ app: Application) throws { guard let token = req.body.string else { throw Abort(.badRequest) // 400 } - guard database.isValid(sessionToken: token) else { + guard server.isValid(sessionToken: token) else { throw Abort(.forbidden) // 403 } - let list = database.getPublicTableInfos() + let list = server.getPublicTableInfos() return try encodeJSON(list) } @@ -238,26 +226,14 @@ func routes(_ app: Application) throws { - 417: The table is already full and can't be joined - Returns: Nothing */ - app.post("table", "join", ":table") { req -> String in - guard let table = req.parameters.get("table"), + app.post("table", "join", ":table") { req -> EventLoopFuture in + guard let string = req.parameters.get("table"), + let table = UUID(uuidString: string), let token = req.body.string else { throw Abort(.badRequest) } - switch database.join(tableId: table, playerToken: token) { - case .success(let table): - return try encodeJSON(table) - case .failure(let result): - switch result { - case .invalidToken: - throw Abort(.unauthorized) // 401 - case .alreadyJoinedOtherTable: - throw Abort(.forbidden) // 403 - case .tableNotFound: - throw Abort(.gone) // 410 - case .tableIsFull: - throw Abort(.expectationFailed) // 417 - } - } + return server.join(tableId: table, playerToken: token, in: req.db) + .flatMapThrowing(encodeJSON) } /** @@ -268,14 +244,11 @@ func routes(_ app: Application) throws { - 401: The session token is invalid - Returns: Nothing */ - app.post("table", "leave") { req -> String in + app.post("table", "leave") { req -> EventLoopFuture in guard let token = req.body.string else { throw Abort(.badRequest) } - guard database.leaveTable(playerToken: token) else { - throw Abort(.unauthorized) // 401 - } - return "" + return server.leaveTable(playerToken: token, in: req.db).map { "" } } app.post("player", "action", ":action") { req -> String in @@ -285,9 +258,9 @@ func routes(_ app: Application) throws { } let result: PlayerActionResult if let action = PlayerAction(rawValue: actionString) { - result = database.performAction(playerToken: token, action: action) + result = server.performAction(playerToken: token, action: action) } else if let game = GameType(rawValue: actionString) { - result = database.select(game: game, playerToken: token) + result = server.select(game: game, playerToken: token) } else { throw Abort(.badRequest) } @@ -313,7 +286,7 @@ func routes(_ app: Application) throws { let card = Card(id: cardId) else { throw Abort(.badRequest) } - switch database.play(card: card, playerToken: token) { + switch server.play(card: card, playerToken: token) { case .success: return "" case .invalidToken: