Add table persistence, organize files

This commit is contained in:
Christoph Hagen 2021-12-01 22:47:19 +01:00
parent 7265fd0f0d
commit cde63c03d6
9 changed files with 410 additions and 214 deletions

View File

@ -0,0 +1,32 @@
import Foundation
import WebSocketKit
private let encoder = JSONEncoder()
enum ClientMessageType: String {
/// The names and connection states of th player, plus table name and id
case tableInfo = "t"
/// The hand cards of the player and the cards on the table
case cardInfo = "c"
/// The game is in the bidding phase
case biddingInfo = "b"
///
}
protocol ClientMessage: Encodable {
static var type: ClientMessageType { get }
}
extension WebSocket {
func send<T>(_ data: T) where T: ClientMessage {
let json = try! encoder.encode(data)
let string = String(data: json, encoding: .utf8)!
self.send(T.type.rawValue + string)
}
}

View File

@ -9,9 +9,7 @@ final class Database {
init(storageFolder: URL) throws { init(storageFolder: URL) throws {
self.players = try PlayerManagement(storageFolder: storageFolder) self.players = try PlayerManagement(storageFolder: storageFolder)
self.tables = TableManagement() self.tables = try TableManagement(storageFolder: storageFolder)
// TODO: Load table data from disk
// TODO: Save data to disk
} }
// MARK: Players & Sessions // MARK: Players & Sessions
@ -49,10 +47,6 @@ final class Database {
guard let player = players.endSession(forSessionToken: sessionToken) else { guard let player = players.endSession(forSessionToken: sessionToken) else {
return return
} }
closeSession(for: player)
}
private func closeSession(for player: PlayerName) {
tables.disconnect(player: player) tables.disconnect(player: player)
} }
@ -104,4 +98,11 @@ final class Database {
tables.remove(player: player) tables.remove(player: player)
return true return true
} }
func dealCards(playerToken: SessionToken) -> DealCardResult {
guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else {
return .invalidToken
}
return tables.dealCards(player: player)
}
} }

View File

@ -0,0 +1,62 @@
//
// File.swift
//
//
// Created by iMac on 01.12.21.
//
import Foundation
protocol DiskWriter {
var storageFile: FileHandle { get }
var storageFileUrl: URL { get }
}
extension DiskWriter {
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 readLinesFromDisk() throws -> [String] {
if #available(macOS 10.15.4, *) {
guard let data = try storageFile.readToEnd() else {
try storageFile.seekToEnd()
return []
}
return parseLines(data: data)
} else {
let data = storageFile.readDataToEndOfFile()
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

@ -5,7 +5,7 @@ typealias PasswordHash = String
typealias SessionToken = String typealias SessionToken = String
/// Manages player registration, session tokens and password hashes /// Manages player registration, session tokens and password hashes
final class PlayerManagement { final class PlayerManagement: DiskWriter {
/// A mapping between player name and their password hashes /// A mapping between player name and their password hashes
private var playerPasswordHashes = [PlayerName: PasswordHash]() private var playerPasswordHashes = [PlayerName: PasswordHash]()
@ -16,74 +16,41 @@ final class PlayerManagement {
/// A reverse mapping between generated access tokens and player name /// A reverse mapping between generated access tokens and player name
private var playerNameForToken = [SessionToken: PlayerName]() private var playerNameForToken = [SessionToken: PlayerName]()
private let passwordFile: FileHandle let storageFile: FileHandle
private let passwordFileUrl: URL let storageFileUrl: URL
init(storageFolder: URL) throws { init(storageFolder: URL) throws {
let url = storageFolder.appendingPathComponent("passwords.txt") let url = storageFolder.appendingPathComponent("passwords.txt")
if !FileManager.default.fileExists(atPath: url.path) { storageFileUrl = url
try Data().write(to: url) storageFile = try Self.prepareFile(at: url)
}
passwordFile = try FileHandle(forUpdating: url) try readLinesFromDisk().forEach { line in
passwordFileUrl = url let parts = line.components(separatedBy: ":")
// Token may contain the separator
if #available(macOS 10.15.4, *) { guard parts.count >= 2 else {
guard let data = try passwordFile.readToEnd() else { print("Invalid line in password file")
try passwordFile.seekToEnd()
return return
} }
try loadPasswords(data: data) let name = parts[0]
} else { let token = parts.dropFirst().joined(separator: ":")
let data = passwordFile.readDataToEndOfFile() if token == "" {
try loadPasswords(data: data) playerPasswordHashes[name] = nil
} else {
playerPasswordHashes[name] = token
}
} }
print("Loaded \(playerPasswordHashes.count) players") 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 { private func save(password: PasswordHash, forPlayer player: PlayerName) -> Bool {
let entry = player + ":" + password + "\n" writeToDisk(line: player + ":" + password)
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 { private func deletePassword(forPlayer player: PlayerName) -> Bool {
save(password: "", forPlayer: player) writeToDisk(line: player + ":")
} }
/** /**

View File

@ -0,0 +1,252 @@
import Foundation
import WebSocketKit
import Vapor
let maximumPlayersPerTable = 4
typealias TableId = String
typealias TableName = String
final class TableManagement: DiskWriter {
/// 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]()
private var tablePhase = [TableId: GamePhase]()
let storageFile: FileHandle
let storageFileUrl: URL
init(storageFolder: URL) throws {
let url = storageFolder.appendingPathComponent("tables.txt")
storageFileUrl = url
storageFile = try Self.prepareFile(at: url)
var entries = [TableId : (name: TableName, public: Bool, players: [PlayerName])]()
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 name = parts[1]
let isPublic = parts[2] == "public"
let players = parts[3].components(separatedBy: ",")
if name == "" {
entries[id] = nil
} else {
entries[id] = (name, isPublic, players)
}
}
entries.forEach { id, table in
tableNames[id] = table.name
if table.public {
publicTables.insert(id)
}
tablePlayers[id] = table.players
tablePhase[id] = .waitingForPlayers
for player in table.players {
playerTables[player] = id
}
}
print("Loaded \(tableNames.count) tables")
}
@discardableResult
private func save(table tableId: TableId) -> Bool {
let name = tableNames[tableId]!
let visible = publicTables.contains(tableId) ? "public" : "private"
let players = tablePlayers[tableId]!
let entry = [tableId, name, visible, players.joined(separator: ",")].joined(separator: ":")
return writeToDisk(line: entry)
}
@discardableResult
private func deleteTable(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
- 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)
}
save(table: tableId)
return tableId
}
func getPublicTableInfos() -> [TableInfo] {
publicTables.map(tableInfo).sorted()
}
private func tableInfo(id tableId: TableId) -> TableInfo {
let players = tablePlayers[tableId]!.map(playerState)
return TableInfo(
id: tableId,
name: tableNames[tableId]!,
players: players,
tableIsFull: players.count == maximumPlayersPerTable)
}
private func playerState(_ player: PlayerName) -> TableInfo.PlayerState {
.init(name: player, connected: playerIsConnected(player))
}
private func playerIsConnected(_ player: PlayerName) -> Bool {
playerConnections[player] != nil
}
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[player] = tableId
save(table: tableId)
return .success
}
func remove(player: PlayerName, fromTable tableId: TableId) {
tablePlayers[tableId] = tablePlayers[tableId]?.filter { $0 != player }
disconnect(player: player)
playerTables[player] = nil
// TODO: End game if needed
// TODO: Remove table if empty
save(table: tableId)
}
func remove(player: PlayerName) {
guard let tableId = playerTables[player] else {
return
}
// Already saves table to disk
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) wants updates for table \(tableId.prefix(5)) it didn't join")
return false
}
playerConnections[player] = socket
sendTableInfo(toTable: tableId)
// TODO: Send cards to player
return true
}
func disconnect(player: PlayerName) {
if let socket = playerConnections.removeValue(forKey: player) {
if !socket.isClosed {
_ = socket.close()
}
}
guard let tableId = playerTables[player] else {
return
}
sendTableInfo(toTable: tableId)
// Change table phase to waiting
}
private func sendTableInfo(toTable tableId: TableId) {
let name = tableNames[tableId]!
var players = tablePlayers[tableId]!
let isFull = players.count == maximumPlayersPerTable
for _ in players.count..<maximumPlayersPerTable {
players.append("")
}
let states = players.map(playerState)
players.enumerated().forEach { index, player in
guard let socket = playerConnections[player] else {
return
}
let info = TableInfo(
id: tableId,
name: name,
players: states.rotated(toStartAt: index),
tableIsFull: isFull)
socket.send(info)
}
}
func dealCards(player: PlayerName) -> DealCardResult {
guard let tableId = playerTables[player] else {
return .noTableJoined
}
guard let players = tablePlayers[tableId] else {
playerTables[player] = nil
print("Player \(player) assigned to missing table \(tableId.prefix(5))")
return .noTableJoined
}
guard players.count == maximumPlayersPerTable else {
return .tableNotFull
}
let cards = Dealer.deal()
let handCards = ["", "", "", ""]
players.enumerated().forEach { index, player in
guard let socket = playerConnections[player] else {
return
}
let info = CardInfo(
cards: cards[index].map { .init(card: $0.id, playable: false) },
tableCards: handCards.rotated(toStartAt: index))
socket.send(info)
}
return .success
}
}

View File

@ -1,10 +1,11 @@
import Foundation import Foundation
import Vapor
typealias CardId = String typealias CardId = String
struct Card { struct Card: Codable {
enum Symbol: Character { enum Symbol: Character, CaseIterable, Codable {
case ass = "A" case ass = "A"
case zehn = "Z" case zehn = "Z"
case könig = "K" case könig = "K"
@ -13,16 +14,32 @@ struct Card {
case neun = "9" case neun = "9"
case acht = "8" case acht = "8"
case sieben = "7" 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 { enum Suit: Character, CaseIterable, Codable {
case eichel = "E" case eichel = "E"
case blatt = "B" case blatt = "B"
case herz = "H" case herz = "H"
case schelln = "S" case schelln = "S"
} }
let symbol: Symbol let symbol: Symbol
let suit: Suit let suit: Suit
@ -32,6 +49,11 @@ struct Card {
self.symbol = symbol self.symbol = symbol
} }
init(_ suit: Suit, _ symbol: Symbol) {
self.suit = suit
self.symbol = symbol
}
init?(rawValue: String) { init?(rawValue: String) {
guard rawValue.count == 2 else { guard rawValue.count == 2 else {
return nil return nil
@ -45,14 +67,22 @@ struct Card {
} }
var id: CardId { var id: CardId {
"\(suit)\(symbol)" "\(suit.rawValue)\(symbol.rawValue)"
}
var points: Int {
symbol.points
} }
} }
extension Card: CustomStringConvertible { extension Card: CustomStringConvertible {
var description: String { var description: String {
id "\(suit) \(symbol)"
} }
} }
extension Card: Hashable {
}

View File

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

View File

@ -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()
}
}