Switch to SQLite database over text files
This commit is contained in:
parent
86456b2441
commit
5eafcfdf4d
@ -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)
|
||||
}
|
||||
}
|
@ -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 != "" }
|
||||
}
|
||||
}
|
@ -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]
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -1,107 +1,34 @@
|
||||
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 = 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
|
||||
let id = table.id!
|
||||
self.tables[id] = WaitingTable(id: id, name: table.name, isPublic: table.isPublic, players: table.players)
|
||||
}
|
||||
if entries[id] != nil {
|
||||
redundantEntries += 1
|
||||
print("\(self.tables.count) tables loaded")
|
||||
}
|
||||
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
|
||||
- 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<TableInfo> {
|
||||
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<TableInfo, JoinTableResult> {
|
||||
if let existing = currentTable(for: player) {
|
||||
func join(tableId: UUID, player: User, in database: Database) -> EventLoopFuture<TableInfo> {
|
||||
return database.eventLoop.future().flatMapThrowing { _ -> ManageableTable in
|
||||
if let existing = self.currentTable(for: player.name) {
|
||||
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 {
|
||||
return .failure(.tableNotFound)
|
||||
guard let table = self.tables[tableId] else {
|
||||
throw Abort(.gone) // 410
|
||||
}
|
||||
guard let joinableTable = table as? WaitingTable else {
|
||||
return .failure(.tableIsFull)
|
||||
guard let joinableTable = table as? WaitingTable,
|
||||
joinableTable.add(player: player.name, points: player.points) else {
|
||||
throw Abort(.expectationFailed) // 417
|
||||
}
|
||||
guard joinableTable.add(player: player) else {
|
||||
return .failure(.tableIsFull)
|
||||
return joinableTable
|
||||
}.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
|
||||
- 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<Void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
|
@ -7,7 +7,7 @@ let numberOfCardsPerPlayer = 8
|
||||
class AbstractTable<TablePlayer> 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<TablePlayer> 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<TablePlayer> 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<TablePlayer> 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<TablePlayer> 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] {
|
||||
|
@ -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 }
|
||||
|
@ -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<WaitingPlayer> {
|
||||
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<WaitingPlayer> {
|
||||
- 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<WaitingPlayer> {
|
||||
- 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<WaitingPlayer> {
|
||||
_ = $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<WaitingPlayer> {
|
||||
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<WaitingPlayer> {
|
||||
- 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 {
|
||||
|
@ -1,19 +1,35 @@
|
||||
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)
|
||||
|
@ -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<SessionToken> 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<String> 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<String> 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
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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<String> 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<String> 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<String> 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:
|
||||
|
Loading…
Reference in New Issue
Block a user