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,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<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) {
guard existing.id == tableId else {
return .failure(.alreadyJoinedOtherTable)
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 {
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<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)
}
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<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
}
}