Add table persistence, organize files
This commit is contained in:
88
Sources/App/Model/Card.swift
Normal file
88
Sources/App/Model/Card.swift
Normal file
@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import Vapor
|
||||
|
||||
typealias CardId = String
|
||||
|
||||
struct Card: Codable {
|
||||
|
||||
enum Symbol: Character, CaseIterable, Codable {
|
||||
case ass = "A"
|
||||
case zehn = "Z"
|
||||
case könig = "K"
|
||||
case ober = "O"
|
||||
case unter = "U"
|
||||
case neun = "9"
|
||||
case acht = "8"
|
||||
case sieben = "7"
|
||||
|
||||
var points: Int {
|
||||
switch self {
|
||||
case .ass:
|
||||
return 11
|
||||
case .zehn:
|
||||
return 10
|
||||
case .könig:
|
||||
return 4
|
||||
case .ober:
|
||||
return 3
|
||||
case .unter:
|
||||
return 2
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Suit: Character, CaseIterable, Codable {
|
||||
case eichel = "E"
|
||||
case blatt = "B"
|
||||
case herz = "H"
|
||||
case schelln = "S"
|
||||
}
|
||||
|
||||
let symbol: Symbol
|
||||
|
||||
let suit: Suit
|
||||
|
||||
init(suit: Suit, symbol: Symbol) {
|
||||
self.suit = suit
|
||||
self.symbol = symbol
|
||||
}
|
||||
|
||||
init(_ suit: Suit, _ symbol: Symbol) {
|
||||
self.suit = suit
|
||||
self.symbol = symbol
|
||||
}
|
||||
|
||||
init?(rawValue: String) {
|
||||
guard rawValue.count == 2 else {
|
||||
return nil
|
||||
}
|
||||
guard let suit = Suit(rawValue: rawValue.first!),
|
||||
let symbol = Symbol(rawValue: rawValue.last!) else {
|
||||
return nil
|
||||
}
|
||||
self.suit = suit
|
||||
self.symbol = symbol
|
||||
}
|
||||
|
||||
var id: CardId {
|
||||
"\(suit.rawValue)\(symbol.rawValue)"
|
||||
}
|
||||
|
||||
var points: Int {
|
||||
symbol.points
|
||||
}
|
||||
}
|
||||
|
||||
extension Card: CustomStringConvertible {
|
||||
|
||||
var description: String {
|
||||
"\(suit) \(symbol)"
|
||||
}
|
||||
}
|
||||
|
||||
extension Card: Hashable {
|
||||
|
||||
}
|
||||
|
@ -1,18 +0,0 @@
|
||||
import Foundation
|
||||
import WebSocketKit
|
||||
|
||||
private let encoder = JSONEncoder()
|
||||
|
||||
enum ClientMessageType: String {
|
||||
|
||||
case tableInfo = "t"
|
||||
}
|
||||
|
||||
extension WebSocket {
|
||||
|
||||
func send<T>(_ type: ClientMessageType, data: T) where T: Encodable {
|
||||
let json = try! encoder.encode(data)
|
||||
let string = String(data: json, encoding: .utf8)!
|
||||
self.send(type.rawValue + string)
|
||||
}
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
import Foundation
|
||||
import Crypto
|
||||
|
||||
extension String {
|
||||
|
||||
/**
|
||||
Create a new access token.
|
||||
*/
|
||||
static func newToken() -> String {
|
||||
Crypto.SymmetricKey.init(size: .bits128).withUnsafeBytes {
|
||||
$0.hexEncodedString()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,107 +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 = TableManagement()
|
||||
// TODO: Load table data from disk
|
||||
// TODO: Save data to disk
|
||||
}
|
||||
|
||||
// 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.remove(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
|
||||
}
|
||||
closeSession(for: player)
|
||||
}
|
||||
|
||||
private func closeSession(for player: PlayerName) {
|
||||
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) -> TableId {
|
||||
tables.currentTableOfPlayer(named: 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 visible: Indicates that this is a game joinable by everyone
|
||||
- Returns: The table id
|
||||
*/
|
||||
func createTable(named name: TableName, player: PlayerName, visible: Bool) -> TableId {
|
||||
tables.createTable(named: name, player: player, visible: visible)
|
||||
}
|
||||
|
||||
func getPublicTableInfos() -> [TableInfo] {
|
||||
tables.getPublicTableInfos()
|
||||
}
|
||||
|
||||
func join(tableId: TableId, playerToken: SessionToken) -> JoinTableResult {
|
||||
guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else {
|
||||
return .invalidToken
|
||||
}
|
||||
return tables.join(tableId: tableId, player: player)
|
||||
}
|
||||
|
||||
func leaveTable(playerToken: SessionToken) -> Bool {
|
||||
guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else {
|
||||
return false
|
||||
}
|
||||
tables.remove(player: player)
|
||||
return true
|
||||
}
|
||||
}
|
@ -1,179 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
typealias PlayerName = String
|
||||
typealias PasswordHash = String
|
||||
typealias SessionToken = String
|
||||
|
||||
/// Manages player registration, session tokens and password hashes
|
||||
final class PlayerManagement {
|
||||
|
||||
/// 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]()
|
||||
|
||||
private let passwordFile: FileHandle
|
||||
|
||||
private let passwordFileUrl: URL
|
||||
|
||||
init(storageFolder: URL) throws {
|
||||
let url = storageFolder.appendingPathComponent("passwords.txt")
|
||||
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
try Data().write(to: url)
|
||||
}
|
||||
|
||||
passwordFile = try FileHandle(forUpdating: url)
|
||||
passwordFileUrl = url
|
||||
|
||||
if #available(macOS 10.15.4, *) {
|
||||
guard let data = try passwordFile.readToEnd() else {
|
||||
try passwordFile.seekToEnd()
|
||||
return
|
||||
}
|
||||
try loadPasswords(data: data)
|
||||
} else {
|
||||
let data = passwordFile.readDataToEndOfFile()
|
||||
try loadPasswords(data: data)
|
||||
}
|
||||
print("Loaded \(playerPasswordHashes.count) players")
|
||||
}
|
||||
|
||||
private func loadPasswords(data: Data) throws {
|
||||
String(data: data, encoding: .utf8)!
|
||||
.components(separatedBy: "\n")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { $0 != "" }
|
||||
.forEach { line in
|
||||
let parts = line.components(separatedBy: ":")
|
||||
// Token may contain the separator
|
||||
guard parts.count >= 2 else {
|
||||
print("Invalid line in password file")
|
||||
return
|
||||
}
|
||||
let name = parts[0]
|
||||
let token = parts.dropFirst().joined(separator: ":")
|
||||
if token == "" {
|
||||
playerPasswordHashes[name] = nil
|
||||
} else {
|
||||
playerPasswordHashes[name] = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save(password: PasswordHash, forPlayer player: PlayerName) -> Bool {
|
||||
let entry = player + ":" + password + "\n"
|
||||
let data = entry.data(using: .utf8)!
|
||||
do {
|
||||
if #available(macOS 10.15.4, *) {
|
||||
try passwordFile.write(contentsOf: data)
|
||||
} else {
|
||||
passwordFile.write(data)
|
||||
}
|
||||
try passwordFile.synchronize()
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to save password to disk: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func deletePassword(forPlayer player: PlayerName) -> Bool {
|
||||
save(password: "", forPlayer: 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,130 +0,0 @@
|
||||
import Foundation
|
||||
import WebSocketKit
|
||||
import Vapor
|
||||
|
||||
let maximumPlayersPerTable = 4
|
||||
|
||||
typealias TableId = String
|
||||
typealias TableName = String
|
||||
|
||||
final class TableManagement {
|
||||
|
||||
/// A list of table ids for public games
|
||||
private var publicTables = Set<TableId>()
|
||||
|
||||
/// A mapping from table id to table name (for all tables)
|
||||
private var tableNames = [TableId: TableName]()
|
||||
|
||||
/// A mapping from table id to participating players
|
||||
private var tablePlayers = [TableId: [PlayerName]]()
|
||||
|
||||
/// A reverse list of players and their table id
|
||||
private var playerTables = [PlayerName: TableId]()
|
||||
|
||||
private var playerConnections = [PlayerName : WebSocket]()
|
||||
|
||||
init() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
Create a new table with optional players.
|
||||
- Parameter name: The name of the table
|
||||
- Parameter players: The player creating the table
|
||||
- Parameter visible: Indicates that this is a game joinable by everyone
|
||||
- Returns: The table id
|
||||
*/
|
||||
func createTable(named name: TableName, player: PlayerName, visible: Bool) -> TableId {
|
||||
let tableId = TableId.newToken()
|
||||
|
||||
tableNames[tableId] = name
|
||||
tablePlayers[tableId] = [player]
|
||||
playerTables[player] = tableId
|
||||
|
||||
if visible {
|
||||
publicTables.insert(tableId)
|
||||
}
|
||||
return tableId
|
||||
}
|
||||
|
||||
func getPublicTableInfos() -> [TableInfo] {
|
||||
publicTables.map(tableInfo).sorted()
|
||||
}
|
||||
|
||||
private func tableInfo(id tableId: TableId) -> TableInfo {
|
||||
let players = tablePlayers[tableId]!
|
||||
let connected = players.map { playerConnections[$0] != nil }
|
||||
return TableInfo(
|
||||
id: tableId,
|
||||
name: tableNames[tableId]!,
|
||||
players: players,
|
||||
connected: connected)
|
||||
}
|
||||
|
||||
func currentTableOfPlayer(named player: PlayerName) -> TableId? {
|
||||
playerTables[player]
|
||||
}
|
||||
|
||||
/**
|
||||
Join a table.
|
||||
- Returns: The result of the join operation
|
||||
*/
|
||||
func join(tableId: TableId, player: PlayerName) -> JoinTableResult {
|
||||
guard var players = tablePlayers[tableId] else {
|
||||
return .tableNotFound
|
||||
}
|
||||
guard !players.contains(player) else {
|
||||
return .success
|
||||
}
|
||||
guard players.count < maximumPlayersPerTable else {
|
||||
return .tableIsFull
|
||||
}
|
||||
players.append(player)
|
||||
if let oldTable = playerTables[tableId] {
|
||||
remove(player: player, fromTable: oldTable)
|
||||
}
|
||||
tablePlayers[tableId] = players
|
||||
playerTables[tableId] = tableId
|
||||
return .success
|
||||
}
|
||||
|
||||
func remove(player: PlayerName, fromTable tableId: TableId) {
|
||||
tablePlayers[tableId] = tablePlayers[tableId]?.filter { $0 != player }
|
||||
// TODO: End connection for removed user
|
||||
// TODO: End game if needed, send info to remaining players
|
||||
}
|
||||
|
||||
func remove(player: PlayerName) {
|
||||
guard let tableId = playerTables[player] else {
|
||||
return
|
||||
}
|
||||
remove(player: player, fromTable: tableId)
|
||||
}
|
||||
|
||||
func connect(player: PlayerName, using socket: WebSocket) -> Bool {
|
||||
guard let tableId = playerTables[player] else {
|
||||
return false
|
||||
}
|
||||
guard let players = tablePlayers[tableId] else {
|
||||
print("Player \(player) was assigned to missing table \(tableId.prefix(5))")
|
||||
playerTables[player] = nil
|
||||
return false
|
||||
}
|
||||
guard players.contains(player) else {
|
||||
print("Player \(player) was assigned to table \(tableId.prefix(5)) where it wasn't listed")
|
||||
return false
|
||||
}
|
||||
playerConnections[player] = socket
|
||||
|
||||
let tableInfo = self.tableInfo(id: tableId)
|
||||
// Notify other players at table about changes
|
||||
players
|
||||
.compactMap { playerConnections[$0] }
|
||||
.forEach { $0.send(.tableInfo, data: tableInfo) }
|
||||
return true
|
||||
}
|
||||
|
||||
func disconnect(player: PlayerName) {
|
||||
fatalError()
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user