Add table persistence, organize files
This commit is contained in:
parent
7265fd0f0d
commit
cde63c03d6
32
Sources/App/Management/ClientConnection.swift
Normal file
32
Sources/App/Management/ClientConnection.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
62
Sources/App/Management/DiskWriter.swift
Normal file
62
Sources/App/Management/DiskWriter.swift
Normal 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 != "" }
|
||||||
|
}
|
||||||
|
}
|
@ -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,39 +16,17 @@ 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
|
|
||||||
|
|
||||||
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: ":")
|
let parts = line.components(separatedBy: ":")
|
||||||
// Token may contain the separator
|
// Token may contain the separator
|
||||||
guard parts.count >= 2 else {
|
guard parts.count >= 2 else {
|
||||||
@ -63,27 +41,16 @@ final class PlayerManagement {
|
|||||||
playerPasswordHashes[name] = token
|
playerPasswordHashes[name] = token
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print("Loaded \(playerPasswordHashes.count) players")
|
||||||
}
|
}
|
||||||
|
|
||||||
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 + ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
252
Sources/App/Management/TableManagement.swift
Normal file
252
Sources/App/Management/TableManagement.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -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 {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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,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()
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user