Switch to SQLite database over text files

This commit is contained in:
Christoph Hagen 2021-12-22 22:13:09 +01:00
parent 86456b2441
commit 5eafcfdf4d
10 changed files with 148 additions and 564 deletions

View File

@ -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<TableInfo,JoinTableResult> {
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)
}
}

View File

@ -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 != "" }
}
}

View File

@ -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]
}
}

View File

@ -1,107 +1,34 @@
import Foundation import Foundation
import WebSocketKit import WebSocketKit
import Vapor import Vapor
import Fluent
let maximumPlayersPerTable = 4 let maximumPlayersPerTable = 4
typealias TableId = String typealias TableId = String
typealias TableName = String typealias TableName = String
final class TableManagement: DiskWriter { final class TableManagement {
/// All tables indexed by their id /// All tables indexed by their id
private var tables = [TableId : ManageableTable]() private var tables = [UUID : 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
/** /**
Load the tables from a file in the storage folder 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 - Throws: Errors when the file could not be read
*/ */
init(storageFolder: URL) throws { init(db: Database) throws {
let url = storageFolder.appendingPathComponent("tables.txt") Table.query(on: db).with(\.$players).all().whenSuccess { loadedTables in
for table in loadedTables {
storageFileUrl = url guard !table.players.isEmpty else {
storageFile = try Self.prepareFile(at: url) _ = table.delete(on: db)
continue
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
} }
let id = parts[0] let id = table.id!
let name = parts[1] self.tables[id] = WaitingTable(id: id, name: table.name, isPublic: table.isPublic, players: table.players)
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 { print("\(self.tables.count) tables loaded")
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()
}
}
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)
} }
/** /**
@ -111,11 +38,19 @@ final class TableManagement: DiskWriter {
- Parameter isPublic: Indicates that this is a game joinable by everyone - Parameter isPublic: Indicates that this is a game joinable by everyone
- Returns: The table id - Returns: The table id
*/ */
func createTable(named name: TableName, player: PlayerName, isPublic: Bool) -> TableInfo { func createTable(named name: TableName, player: User, isPublic: Bool, in database: Database) -> EventLoopFuture<TableInfo> {
let table = WaitingTable(newTable: name, isPublic: isPublic, creator: player) let table = Table(name: name, isPublic: isPublic)
tables[table.id] = table return table.create(on: database).flatMap {
writeTableToDisk(table: table) player.$table.id = table.id
return table.tableInfo(forPlayer: player) 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 /// 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. - Parameter player: The name of the player who wants to join.
- Returns: The result of the join operation - Returns: The result of the join operation
*/ */
func join(tableId: TableId, player: PlayerName) -> Result<TableInfo, JoinTableResult> { func join(tableId: UUID, player: User, in database: Database) -> EventLoopFuture<TableInfo> {
if let existing = currentTable(for: player) { return database.eventLoop.future().flatMapThrowing { _ -> ManageableTable in
if let existing = self.currentTable(for: player.name) {
guard existing.id == tableId else { guard existing.id == tableId else {
return .failure(.alreadyJoinedOtherTable) throw Abort(.forbidden) // 403
} }
return .success(existing.tableInfo(forPlayer: player)) return existing
} }
guard let table = tables[tableId] else { guard let table = self.tables[tableId] else {
return .failure(.tableNotFound) throw Abort(.gone) // 410
} }
guard let joinableTable = table as? WaitingTable else { guard let joinableTable = table as? WaitingTable,
return .failure(.tableIsFull) joinableTable.add(player: player.name, points: player.points) else {
throw Abort(.expectationFailed) // 417
} }
guard joinableTable.add(player: player) else { return joinableTable
return .failure(.tableIsFull) }.flatMap { table -> EventLoopFuture<ManageableTable> in
player.$table.id = table.id
return player.update(on: database).map { table }
}.map { table in
table.sendUpdateToAllPlayers()
return table.tableInfo(forPlayer: player.name)
} }
writeTableToDisk(table: table)
joinableTable.sendUpdateToAllPlayers()
return .success(joinableTable.tableInfo(forPlayer: player))
} }
/** /**
A player leaves the table it previously joined 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) { func leaveTable(player: User, in database: Database) -> EventLoopFuture<Void> {
guard let oldTable = currentTable(for: player) else { guard let oldTable = currentTable(for: player.name) else {
return return database.eventLoop.makeSucceededVoidFuture()
} }
/// `player.canStartGame` is automatically set to false, because table is not full /// `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 tables[table.id] = table
table.sendUpdateToAllPlayers() 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 { func connect(player: PlayerName, using socket: WebSocket) -> Bool {
@ -208,7 +149,7 @@ final class TableManagement: DiskWriter {
tables[newTable.id] = newTable tables[newTable.id] = newTable
newTable.sendUpdateToAllPlayers() newTable.sendUpdateToAllPlayers()
if newTable is FinishedTable || newTable is DealingTable { if newTable is FinishedTable || newTable is DealingTable {
writeTableToDisk(table: newTable) // TODO: Save new table
} }
return .success return .success
} }
@ -232,6 +173,7 @@ final class TableManagement: DiskWriter {
} }
tables[newTable.id] = newTable tables[newTable.id] = newTable
newTable.sendUpdateToAllPlayers() newTable.sendUpdateToAllPlayers()
// TODO: Save new table
return .success return .success
} }
@ -249,6 +191,7 @@ final class TableManagement: DiskWriter {
} }
tables[newTable.id] = newTable tables[newTable.id] = newTable
newTable.sendUpdateToAllPlayers() newTable.sendUpdateToAllPlayers()
// TODO: Save new table
return .success return .success
} }
} }

View File

@ -1,24 +1,30 @@
import Foundation import Foundation
import WebSocketKit import WebSocketKit
typealias PlayerName = String
class Player { class Player {
let name: PlayerName let name: PlayerName
let totalPoints: Int
var socket: WebSocket? var socket: WebSocket?
var isNextActor: Bool var isNextActor: Bool
init(name: PlayerName, socket: WebSocket? = nil) { init(name: PlayerName, points: Int, socket: WebSocket? = nil) {
self.name = name self.name = name
self.socket = socket self.socket = socket
self.isNextActor = false self.isNextActor = false
self.totalPoints = points
} }
init(player: Player) { init(player: Player) {
self.name = player.name self.name = player.name
self.socket = player.socket self.socket = player.socket
self.isNextActor = false self.isNextActor = false
self.totalPoints = player.totalPoints
} }
var actions: [PlayerAction] { var actions: [PlayerAction] {
@ -31,6 +37,7 @@ class Player {
var info: PlayerInfo { var info: PlayerInfo {
var result = PlayerInfo(name: name) var result = PlayerInfo(name: name)
result.points = totalPoints
result.isConnected = isConnected result.isConnected = isConnected
result.isNextActor = isNextActor result.isNextActor = isNextActor
result.state = states.map { $0.rawValue } result.state = states.map { $0.rawValue }

View File

@ -7,7 +7,7 @@ let numberOfCardsPerPlayer = 8
class AbstractTable<TablePlayer> where TablePlayer: Player { class AbstractTable<TablePlayer> where TablePlayer: Player {
/// The unique id of the table /// The unique id of the table
let id: TableId let id: UUID
/// The name of the table /// The name of the table
let name: TableName let name: TableName
@ -28,6 +28,10 @@ class AbstractTable<TablePlayer> where TablePlayer: Player {
nil nil
} }
var leavePenalty: Int {
5
}
init(table: ManageableTable, players: [TablePlayer]) { init(table: ManageableTable, players: [TablePlayer]) {
self.id = table.id self.id = table.id
self.name = table.name self.name = table.name
@ -35,7 +39,7 @@ class AbstractTable<TablePlayer> where TablePlayer: Player {
self.players = players 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.id = id
self.name = name self.name = name
self.isPublic = isPublic self.isPublic = isPublic
@ -67,7 +71,7 @@ class AbstractTable<TablePlayer> where TablePlayer: Player {
} }
func tableInfo(forPlayerAt index: Int) -> TableInfo { 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.player = playerInfo(forIndex: index)!
info.playerLeft = playerInfo(forIndex: (index + 1) % 4) info.playerLeft = playerInfo(forIndex: (index + 1) % 4)
info.playerAcross = playerInfo(forIndex: (index + 2) % 4) info.playerAcross = playerInfo(forIndex: (index + 2) % 4)
@ -85,7 +89,7 @@ class AbstractTable<TablePlayer> where TablePlayer: Player {
extension AbstractTable: ManageableTable { extension AbstractTable: ManageableTable {
var publicInfo: PublicTableInfo { var publicInfo: PublicTableInfo {
.init(id: id, name: name, players: playerNames) .init(id: id.uuidString, name: name, players: playerNames)
} }
var playerNames: [PlayerName] { var playerNames: [PlayerName] {

View File

@ -4,7 +4,7 @@ import WebSocketKit
protocol ManageableTable { protocol ManageableTable {
/// The unique id of the table /// The unique id of the table
var id: TableId { get } var id: UUID { get }
/// The name of the table /// The name of the table
var name: TableName { get } 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 /// The table is visible in the list of tables and can be joined by anyone
var isPublic: Bool { get } var isPublic: Bool { get }
var leavePenalty: Int { get }
var playerNames: [PlayerName] { get } var playerNames: [PlayerName] { get }
var allPlayers: [Player] { get } var allPlayers: [Player] { get }

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
import Fluent
/** /**
Represents a table where players are still joining and leaving. Represents a table where players are still joining and leaving.
@ -10,8 +11,8 @@ final class WaitingTable: AbstractTable<WaitingPlayer> {
players.count >= maximumPlayersPerTable players.count >= maximumPlayersPerTable
} }
init(id: TableId, name: TableName, isPublic: Bool, players: [PlayerName]) { init(id: UUID, name: TableName, isPublic: Bool, players: [User]) {
let players = players.map { WaitingPlayer(name: $0) } let players = players.map { WaitingPlayer(name: $0.name, points: $0.points) }
players.first!.isNextActor = true players.first!.isNextActor = true
super.init(id: id, name: name, isPublic: isPublic, players: players) super.init(id: id, name: name, isPublic: isPublic, players: players)
if isFull { if isFull {
@ -24,10 +25,11 @@ final class WaitingTable: AbstractTable<WaitingPlayer> {
- Parameter name: The name of the table - Parameter name: The name of the table
- Parameter isPublic: The table is visible and joinable by everyone - Parameter isPublic: The table is visible and joinable by everyone
*/ */
init(newTable name: TableName, isPublic: Bool, creator: PlayerName) { init(newTable object: Table) {
let player = WaitingPlayer(name: creator) let user = object.players[0]
let player = WaitingPlayer(name: user.name, points: user.points)
player.isNextActor = true 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<WaitingPlayer> {
- Parameter player: The name of the player to remove from the table. - Parameter player: The name of the player to remove from the table.
*/ */
init(oldTable: ManageableTable, removing player: PlayerName) { init(oldTable: ManageableTable, removing player: PlayerName) {
// TODO: End game and distribute points
let players = oldTable.allPlayers let players = oldTable.allPlayers
.filter { .filter {
guard $0.name == player else { guard $0.name == player else {
@ -46,7 +49,7 @@ final class WaitingTable: AbstractTable<WaitingPlayer> {
_ = $0.disconnect() _ = $0.disconnect()
return false 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 players.first!.isNextActor = true
super.init(table: oldTable, players: players) super.init(table: oldTable, players: players)
} }
@ -60,7 +63,7 @@ final class WaitingTable: AbstractTable<WaitingPlayer> {
init(oldTableAdvancedByOne table: ManageableTable) { init(oldTableAdvancedByOne table: ManageableTable) {
let players = table.allPlayers let players = table.allPlayers
.rotatedByOne() .rotatedByOne()
.map { WaitingPlayer(name: $0.name, socket: $0.socket) } .map(WaitingPlayer.init)
super.init(table: table, players: players) super.init(table: table, players: players)
players.forEach { $0.canStartGame = true } players.forEach { $0.canStartGame = true }
players.first!.isNextActor = true players.first!.isNextActor = true
@ -71,11 +74,11 @@ final class WaitingTable: AbstractTable<WaitingPlayer> {
- Parameter player: The name of the player to add - Parameter player: The name of the player to add
- Returns: `true`, if the player could be added, `false` if the table is full - 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 { guard !isFull else {
return false return false
} }
let player = WaitingPlayer(name: player) let player = WaitingPlayer(name: player, points: points)
players.append(player) players.append(player)
// Allow dealing of cards if table is full // Allow dealing of cards if table is full
if isFull { if isFull {

View File

@ -1,19 +1,35 @@
import Vapor import Vapor
import Fluent
var database: Database! var server: SQLiteDatabase!
// configures your application // configures your application
public func configure(_ app: Application) throws { public func configure(_ app: Application) throws {
let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory)
// Set target environment // Set target environment
app.environment = .production 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 // serve files from /Public folder
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory) let db = app.databases.database(.sqlite, logger: .init(label: "Init"), on: app.databases.eventLoopGroup.next())!
database = try Database(storageFolder: storageFolder) server = try SQLiteDatabase(db: db)
// register routes // register routes
try routes(app) try routes(app)

View File

@ -29,7 +29,7 @@ func routes(_ app: Application) throws {
- 424: The password could not be hashed - 424: The password could not be hashed
- Returns: The session token for the registered user - Returns: The session token for the registered user
*/ */
app.post("player", "register", ":name") { req -> String in app.post("player", "register", ":name") { req -> EventLoopFuture<SessionToken> in
guard let name = req.parameters.get("name"), guard let name = req.parameters.get("name"),
let password = req.body.string else { let password = req.body.string else {
throw Abort(.badRequest) // 400 throw Abort(.badRequest) // 400
@ -42,10 +42,8 @@ func routes(_ app: Application) throws {
guard let hash = try? req.password.hash(password) else { guard let hash = try? req.password.hash(password) else {
throw Abort(.failedDependency) // 424 throw Abort(.failedDependency) // 424
} }
guard let token = database.registerPlayer(named: name, hash: hash) else { // Can throw conflict (409)
throw Abort(.conflict) // 409 return server.registerPlayer(named: name, hash: hash, in: req.db)
}
return token
} }
/** /**
@ -58,22 +56,17 @@ func routes(_ app: Application) throws {
- 424: The password could not be hashed - 424: The password could not be hashed
- Returns: Nothing - Returns: Nothing
*/ */
app.post("player", "delete", ":name") { req -> String in app.post("player", "delete", ":name") { req -> EventLoopFuture<String> in
guard let name = req.parameters.get("name"), guard let name = req.parameters.get("name"),
let password = req.body.string else { let password = req.body.string else {
throw Abort(.badRequest) // 400 throw Abort(.badRequest) // 400
} }
guard let hash = database.passwordHashForExistingPlayer(named: name) else { return server.passwordHashForExistingPlayer(named: name, in: req.db)
throw Abort(.forbidden) // 403 .guard({ hash in
} (try? req.password.verify(password, created: hash)) ?? false
guard let isValid = try? req.password.verify(password, created: hash) else { }, else: Abort(.forbidden)).flatMap { _ in
throw Abort(.failedDependency) // 424 server.deletePlayer(named: name, in: req.db)
} }.map { "" }
guard isValid else {
throw Abort(.forbidden) // 403
}
database.deletePlayer(named: name)
return ""
} }
/** /**
@ -86,22 +79,17 @@ func routes(_ app: Application) throws {
- 424: The password could not be hashed - 424: The password could not be hashed
- Returns: The session token for the user - Returns: The session token for the user
*/ */
app.post("player", "login", ":name") { req -> String in app.post("player", "login", ":name") { req -> EventLoopFuture<String> in
guard let name = req.parameters.get("name"), guard let name = req.parameters.get("name"),
let password = req.body.string else { let password = req.body.string else {
throw Abort(.badRequest) // 400 throw Abort(.badRequest) // 400
} }
guard let hash = database.passwordHashForExistingPlayer(named: name) else { return server.passwordHashForExistingPlayer(named: name, in: req.db)
throw Abort(.forbidden) // 403 .guard({ hash in
(try? req.password.verify(password, created: hash)) ?? false
}, else: Abort(.forbidden)).map { _ in
server.startNewSessionForRegisteredPlayer(named: name)
} }
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
} }
/** /**
@ -116,7 +104,7 @@ func routes(_ app: Application) throws {
guard let token = req.body.string else { guard let token = req.body.string else {
throw Abort(.badRequest) // 400 throw Abort(.badRequest) // 400
} }
guard let player = database.registeredPlayerExists(withSessionToken: token) else { guard let player = server.registeredPlayerExists(withSessionToken: token) else {
throw Abort(.unauthorized) // 401 throw Abort(.unauthorized) // 401
} }
return player return player
@ -135,7 +123,7 @@ func routes(_ app: Application) throws {
guard let token = req.body.string else { guard let token = req.body.string else {
throw Abort(.badRequest) // 400 throw Abort(.badRequest) // 400
} }
database.endSession(forSessionToken: token) server.endSession(forSessionToken: token)
return "" return ""
} }
@ -151,10 +139,10 @@ func routes(_ app: Application) throws {
guard let token = req.body.string else { guard let token = req.body.string else {
throw Abort(.badRequest) // 400 throw Abort(.badRequest) // 400
} }
guard let player = database.registeredPlayerExists(withSessionToken: token) else { guard let player = server.registeredPlayerExists(withSessionToken: token) else {
throw Abort(.unauthorized) // 401 throw Abort(.unauthorized) // 401
} }
guard let info = database.currentTableOfPlayer(named: player) else { guard let info = server.currentTableOfPlayer(named: player) else {
return "" return ""
} }
return try encodeJSON(info) return try encodeJSON(info)
@ -167,7 +155,7 @@ func routes(_ app: Application) throws {
*/ */
app.webSocket("session", "start") { req, socket in app.webSocket("session", "start") { req, socket in
socket.onText { socket, text in socket.onText { socket, text in
guard database.startSession(socket: socket, sessionToken: text) else { guard server.startSession(socket: socket, sessionToken: text) else {
_ = socket.close() _ = socket.close()
return return
} }
@ -185,7 +173,7 @@ func routes(_ app: Application) throws {
- 400: Missing token, table name or invalid visibility - 400: Missing token, table name or invalid visibility
- 401: The session token is invalid - 401: The session token is invalid
*/ */
app.post("table", "create", ":visibility", ":name") { req -> String in app.post("table", "create", ":visibility", ":name") { req -> EventLoopFuture<String> in
guard let visibility = req.parameters.get("visibility"), guard let visibility = req.parameters.get("visibility"),
let tableName = req.parameters.get("name"), let tableName = req.parameters.get("name"),
let token = req.body.string else { let token = req.body.string else {
@ -200,11 +188,11 @@ func routes(_ app: Application) throws {
throw Abort(.badRequest) // 400 throw Abort(.badRequest) // 400
} }
guard let player = database.registeredPlayerExists(withSessionToken: token) else { guard let player = server.registeredPlayerExists(withSessionToken: token) else {
throw Abort(.unauthorized) // 401 throw Abort(.unauthorized) // 401
} }
let table = database.createTable(named: tableName, player: player, isPublic: isPublic) return server.createTable(named: tableName, player: player, isPublic: isPublic, in: req.db)
return try encodeJSON(table) .flatMapThrowing(encodeJSON)
} }
/** /**
@ -219,10 +207,10 @@ func routes(_ app: Application) throws {
guard let token = req.body.string else { guard let token = req.body.string else {
throw Abort(.badRequest) // 400 throw Abort(.badRequest) // 400
} }
guard database.isValid(sessionToken: token) else { guard server.isValid(sessionToken: token) else {
throw Abort(.forbidden) // 403 throw Abort(.forbidden) // 403
} }
let list = database.getPublicTableInfos() let list = server.getPublicTableInfos()
return try encodeJSON(list) return try encodeJSON(list)
} }
@ -238,26 +226,14 @@ func routes(_ app: Application) throws {
- 417: The table is already full and can't be joined - 417: The table is already full and can't be joined
- Returns: Nothing - Returns: Nothing
*/ */
app.post("table", "join", ":table") { req -> String in app.post("table", "join", ":table") { req -> EventLoopFuture<String> in
guard let table = req.parameters.get("table"), guard let string = req.parameters.get("table"),
let table = UUID(uuidString: string),
let token = req.body.string else { let token = req.body.string else {
throw Abort(.badRequest) throw Abort(.badRequest)
} }
switch database.join(tableId: table, playerToken: token) { return server.join(tableId: table, playerToken: token, in: req.db)
case .success(let table): .flatMapThrowing(encodeJSON)
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
}
}
} }
/** /**
@ -268,14 +244,11 @@ func routes(_ app: Application) throws {
- 401: The session token is invalid - 401: The session token is invalid
- Returns: Nothing - Returns: Nothing
*/ */
app.post("table", "leave") { req -> String in app.post("table", "leave") { req -> EventLoopFuture<String> in
guard let token = req.body.string else { guard let token = req.body.string else {
throw Abort(.badRequest) throw Abort(.badRequest)
} }
guard database.leaveTable(playerToken: token) else { return server.leaveTable(playerToken: token, in: req.db).map { "" }
throw Abort(.unauthorized) // 401
}
return ""
} }
app.post("player", "action", ":action") { req -> String in app.post("player", "action", ":action") { req -> String in
@ -285,9 +258,9 @@ func routes(_ app: Application) throws {
} }
let result: PlayerActionResult let result: PlayerActionResult
if let action = PlayerAction(rawValue: actionString) { 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) { } else if let game = GameType(rawValue: actionString) {
result = database.select(game: game, playerToken: token) result = server.select(game: game, playerToken: token)
} else { } else {
throw Abort(.badRequest) throw Abort(.badRequest)
} }
@ -313,7 +286,7 @@ func routes(_ app: Application) throws {
let card = Card(id: cardId) else { let card = Card(id: cardId) else {
throw Abort(.badRequest) throw Abort(.badRequest)
} }
switch database.play(card: card, playerToken: token) { switch server.play(card: card, playerToken: token) {
case .success: case .success:
return "" return ""
case .invalidToken: case .invalidToken: