Play cards, select game, player actions

This commit is contained in:
Christoph Hagen 2021-12-06 18:28:35 +01:00
parent ca7fc858c2
commit fa3aaadef8
11 changed files with 304 additions and 128 deletions

View File

@ -9,16 +9,26 @@ struct PlayerInfo: Codable, Equatable {
/// The player is the next one to perform an action /// The player is the next one to perform an action
let active: Bool let active: Bool
let selectsGame: Bool
/// The cards in the hand of the player /// The cards in the hand of the player
let cards: [CardInfo] let cards: [CardInfo]
/// The action the player can perform /// The action the player can perform
let actions: [String] let actions: [String]
init(player: Player, isMasked: Bool) { let playedCard: CardId?
/// The height of the player card on the table stack
let position: Int
init(player: Player, isMasked: Bool, trickPosition: Int) {
self.name = player.name self.name = player.name
self.connected = player.isConnected self.connected = player.isConnected
self.active = player.isNextActor self.active = player.isNextActor
self.selectsGame = player.selectsGame
self.playedCard = player.playedCard?.id
self.position = trickPosition
if isMasked { if isMasked {
self.cards = [] self.cards = []
self.actions = [] self.actions = []

View File

@ -14,13 +14,23 @@ struct TableInfo: Codable {
let playerRight: PlayerInfo? let playerRight: PlayerInfo?
let playableGames: [GameId]
init(_ table: Table, forPlayerAt playerIndex: Int) { init(_ table: Table, forPlayerAt playerIndex: Int) {
let player = table.player(at: playerIndex)!
self.id = table.id self.id = table.id
self.name = table.name self.name = table.name
self.player = table.player(at: playerIndex)!.info(masked: false) self.player = table.playerInfo(at: playerIndex, masked: false)!
self.playerLeft = table.player(leftOf: playerIndex)?.info(masked: true) self.playerLeft = table.playerInfo(leftOf: playerIndex, masked: true)
self.playerAcross = table.player(acrossOf: playerIndex)?.info(masked: true) self.playerAcross = table.playerInfo(acrossOf: playerIndex, masked: true)
self.playerRight = table.player(rightOf: playerIndex)?.info(masked: true) self.playerRight = table.playerInfo(rightOf: playerIndex, masked: true)
if table.phase == .selectGame, player.selectsGame {
let games = table.minimumPlayableGame?.availableGames ?? GameType.allCases
self.playableGames = games.filter(player.canPlay).map { $0.id }
} else {
self.playableGames = []
}
} }
} }

View File

@ -106,6 +106,13 @@ final class Database {
return tables.performAction(player: player, action: action) 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) -> PlayCardResult { func play(card: Card, playerToken: SessionToken) -> PlayCardResult {
guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else { guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else {
return .invalidToken return .invalidToken

View File

@ -167,11 +167,20 @@ final class TableManagement: DiskWriter {
func performAction(player: PlayerName, action: Player.Action) -> PlayerActionResult { func performAction(player: PlayerName, action: Player.Action) -> PlayerActionResult {
guard let table = currentTable(for: player) else { guard let table = currentTable(for: player) else {
print("Player \(player) wants to \(action.path), but no table joined")
return .noTableJoined return .noTableJoined
} }
return table.perform(action: action, forPlayer: player) return table.perform(action: action, forPlayer: player)
} }
func select(game: GameType, player: PlayerName) -> PlayerActionResult {
guard let table = currentTable(for: player) else {
print("Player \(player) wants to play \(game.rawValue), but no table joined")
return .noTableJoined
}
return table.select(game: game, player: player)
}
func play(card: Card, player: PlayerName) -> PlayCardResult { func play(card: Card, player: PlayerName) -> PlayCardResult {
guard let table = currentTable(for: player) else { guard let table = currentTable(for: player) else {
return .noTableJoined return .noTableJoined

View File

@ -26,7 +26,7 @@ extension CardOrder {
static func highCardIndex(cards: [Card]) -> Int { static func highCardIndex(cards: [Card]) -> Int {
let high: Card let high: Card
if isTrump(cards[0]) { if hasTrump(in: cards) {
high = sort(cards).first! high = sort(cards).first!
} else { } else {
let suit = cards.first!.suit let suit = cards.first!.suit
@ -66,4 +66,8 @@ extension CardOrder {
static func cards(with suit: Card.Suit, in cards: [Card]) -> [Card] { static func cards(with suit: Card.Suit, in cards: [Card]) -> [Card] {
cards.filter { !isTrump($0) && $0.suit == suit } cards.filter { !isTrump($0) && $0.suit == suit }
} }
static func hasCardToCall(_ suit: Card.Suit, in cards: [Card]) -> Bool {
cards.contains { $0.symbol != .ass && $0.suit == suit && !isTrump($0) }
}
} }

View File

@ -0,0 +1,61 @@
import Foundation
typealias GameId = String
extension GameType {
var gameClass: GameClass {
switch self {
case .rufEichel, .rufBlatt, .rufSchelln, .hochzeit:
return .ruf
case .bettel:
return .bettel
case .wenz, .geier:
return .wenzGeier
case .soloEichel, .soloBlatt, .soloHerz, .soloSchelln:
return .solo
}
}
enum GameClass: Int {
case ruf = 1
case bettel = 2
case wenzGeier = 3
case solo = 4
var cost: Int {
switch self {
case .ruf: return 5
case .bettel: return 15
case .wenzGeier, .solo: return 20
}
}
mutating func increase() {
guard self != .solo else {
return
}
self = .init(rawValue: rawValue + 1)!
}
var availableGames: [GameType] {
switch self {
case .ruf:
return GameType.allCases
case .bettel:
return [.bettel, .wenz, .geier, .soloEichel, .soloBlatt, .soloHerz, .soloSchelln]
case .wenzGeier:
return [.wenz, .geier, .soloEichel, .soloBlatt, .soloHerz, .soloSchelln]
case .solo:
return [.soloEichel, .soloBlatt, .soloHerz, .soloSchelln]
}
}
}
}
extension GameType.GameClass: Comparable {
static func < (lhs: GameType.GameClass, rhs: GameType.GameClass) -> Bool {
lhs.rawValue < rhs.rawValue
}
}

View File

@ -1,53 +1,18 @@
import Foundation import Foundation
enum GameType: Codable { enum GameType: String, CaseIterable, Codable {
enum GameClass: Int { case rufEichel = "ruf-eichel"
case ruf = 1 case rufBlatt = "ruf-blatt"
case bettel = 2 case rufSchelln = "ruf-schelln"
case wenzGeier = 3 case hochzeit = "hochzeit"
case solo = 4 case bettel = "bettel"
case wenz = "wenz"
var cost: Int { case geier = "geier"
switch self { case soloEichel = "solo-eichel"
case .ruf: return 5 case soloBlatt = "solo-blatt"
case .bettel: return 15 case soloHerz = "solo-herz"
case .wenzGeier, .solo: return 20 case soloSchelln = "solo-schelln"
}
}
mutating func increase() {
guard self != .solo else {
return
}
self = .init(rawValue: rawValue + 1)!
}
}
case rufEichel
case rufBlatt
case rufSchelln
case hochzeit
case bettel
case wenz
case geier
case soloEichel
case soloBlatt
case soloHerz
case soloSchelln
var gameClass: GameClass {
switch self {
case .rufEichel, .rufBlatt, .rufSchelln, .hochzeit:
return .ruf
case .bettel:
return .bettel
case .wenz, .geier:
return .wenzGeier
case .soloEichel, .soloBlatt, .soloHerz, .soloSchelln:
return .solo
}
}
var isCall: Bool { var isCall: Bool {
switch self { switch self {
@ -84,6 +49,10 @@ enum GameType: Codable {
gameClass.cost gameClass.cost
} }
var id: GameId {
rawValue
}
var sortingType: CardOrder.Type { var sortingType: CardOrder.Type {
switch self { switch self {
case .wenz: case .wenz:

View File

@ -86,7 +86,9 @@ final class Player {
func play(card: Card) { func play(card: Card) {
remove(card: card) remove(card: card)
playedCard = card playedCard = card
actions = actions.filter { $0 != .doubleDuringGame }
} }
func connect(using socket: WebSocket) { func connect(using socket: WebSocket) {
_ = self.socket?.close() _ = self.socket?.close()
self.socket = socket self.socket = socket
@ -113,11 +115,15 @@ final class Player {
actions.contains(action) actions.contains(action)
} }
func prepareForFirstGame(isFirstPlayer: Bool) { func prepareForNewGame(isFirstPlayer: Bool) {
playsFirstCard = isFirstPlayer playsFirstCard = isFirstPlayer
isNextActor = isFirstPlayer isNextActor = isFirstPlayer
selectsGame = false
startedCurrentTrick = isFirstPlayer startedCurrentTrick = isFirstPlayer
actions = [.deal] actions = [.deal]
didDoubleAfterFourCards = nil
isStillBidding = true
isGameLeader = false
handCards = [] handCards = []
playedCard = nil playedCard = nil
wonTricks = [] wonTricks = []
@ -152,6 +158,7 @@ final class Player {
func offerWedding() { func offerWedding() {
offersWedding = true offersWedding = true
isStillBidding = false
actions = [] actions = []
} }
@ -171,14 +178,8 @@ final class Player {
func weddingOutbid() { func weddingOutbid() {
isNextActor = false isNextActor = false
guard isStillBidding else {
return
}
actions = [] actions = []
if offersWedding { offersWedding = false
offersWedding = false
isStillBidding = false
}
} }
func didPerformBid() { func didPerformBid() {
@ -221,8 +222,26 @@ final class Player {
numberOfRaises += 1 numberOfRaises += 1
} }
func canPlay(game: GameType) -> Bool {
guard let suit = game.calledSuit else {
if game == .hochzeit {
return canOfferWedding
}
return true
}
let sorter = game.sortingType
let cards = rawCards
guard sorter.hasCardToCall(suit, in: cards) else {
// Player needs at least one card of the called suit
return false
}
let ace = Card(suit, .ass)
return !cards.contains(ace)
}
func mustSelectGame() { func mustSelectGame() {
isNextActor = true isNextActor = true
selectsGame = true
} }
func replace(card: Card, with other: Card) { func replace(card: Card, with other: Card) {
@ -237,11 +256,15 @@ final class Player {
return removed return removed
} }
func gameStarts() { func start(game: GameType) {
isNextActor = playsFirstCard isNextActor = playsFirstCard
startedCurrentTrick = playsFirstCard startedCurrentTrick = playsFirstCard
selectsGame = false
actions = [.doubleDuringGame] actions = [.doubleDuringGame]
isGameLeader = false isGameLeader = false
if playsFirstCard {
setPlayableCardsForStarter(game: game)
}
} }
func switchLeadership() { func switchLeadership() {
@ -254,6 +277,7 @@ final class Player {
} }
func withdrawFromBidding() { func withdrawFromBidding() {
isNextActor = false
isStillBidding = false isStillBidding = false
actions = [] actions = []
} }
@ -263,6 +287,8 @@ final class Player {
playedCard = nil playedCard = nil
if canDoubleInNextRound, !isGameLeader { if canDoubleInNextRound, !isGameLeader {
actions = [.doubleDuringGame] actions = [.doubleDuringGame]
} else {
actions = []
} }
} }
@ -390,8 +416,8 @@ final class Player {
} }
} }
func info(masked: Bool) -> PlayerInfo { func info(masked: Bool, positionInTrick: Int) -> PlayerInfo {
.init(player: self, isMasked: masked) .init(player: self, isMasked: masked, trickPosition: positionInTrick)
} }
} }
@ -410,40 +436,6 @@ extension Player {
} }
} }
extension Player {
enum Action: String, Codable {
/// The player can request cards to be dealt
case deal = "deal"
/// The player doubles on the initial four cards
case initialDoubleCost = "double"
/// The player does not double on the initial four cards
case noDoubleCost = "skip"
/// The player offers a wedding (one trump card)
case offerWedding = "wedding"
/// The player can choose to accept the wedding
case acceptWedding = "accept"
/// The player matches or increases the game during auction
case increaseOrMatchGame = "bid"
/// The player does not want to play
case withdrawFromAuction = "out"
/// The player claims to win and doubles the game cost ("schießen")
case doubleDuringGame = "raise"
/// The url path for the client to call (e.g. /player/deal)
var path: String {
rawValue
}
}
}
extension Player: Equatable { extension Player: Equatable {
static func == (lhs: Player, rhs: Player) -> Bool { static func == (lhs: Player, rhs: Player) -> Bool {

View File

@ -0,0 +1,35 @@
import Foundation
extension Player {
enum Action: String, Codable {
/// The player can request cards to be dealt
case deal = "deal"
/// The player doubles on the initial four cards
case initialDoubleCost = "double"
/// The player does not double on the initial four cards
case noDoubleCost = "skip"
/// The player offers a wedding (one trump card)
case offerWedding = "wedding"
/// The player can choose to accept the wedding
case acceptWedding = "accept"
/// The player matches or increases the game during auction
case increaseOrMatchGame = "bid"
/// The player does not want to play
case withdrawFromAuction = "out"
/// The player claims to win and doubles the game cost ("schießen")
case doubleDuringGame = "raise"
/// The url path for the client to call (e.g. /player/deal)
var path: String {
rawValue
}
}
}

View File

@ -32,6 +32,11 @@ final class Table {
!players.contains { $0.didDoubleAfterFourCards == nil } !players.contains { $0.didDoubleAfterFourCards == nil }
} }
/// At least one double exists after all players acted on their first cards
var initialDoubleExists: Bool {
players.contains { $0.didDoubleAfterFourCards == true }
}
var weddingOfferExists: Bool { var weddingOfferExists: Bool {
players.contains { $0.offersWedding } players.contains { $0.offersWedding }
} }
@ -106,16 +111,23 @@ final class Table {
players.first { $0.name == player } players.first { $0.name == player }
} }
func player(leftOf index: Int) -> Player? { func playerInfo(at index: Int, masked: Bool) -> PlayerInfo? {
player(at: (index + 1) % 4) let position = players.firstIndex { $0.startedCurrentTrick }!
let layer = (index - position + 4) % 4
//
return player(at: index)?.info(masked: masked, positionInTrick: layer)
} }
func player(acrossOf index: Int) -> Player? { func playerInfo(leftOf index: Int, masked: Bool) -> PlayerInfo? {
player(at: (index + 2) % 4) playerInfo(at: (index + 1) % 4, masked: masked)
} }
func player(rightOf index: Int) -> Player? { func playerInfo(acrossOf index: Int, masked: Bool) -> PlayerInfo? {
player(at: (index + 3) % 4) playerInfo(at: (index + 2) % 4, masked: masked)
}
func playerInfo(rightOf index: Int, masked: Bool) -> PlayerInfo? {
playerInfo(at: (index + 3) % 4, masked: masked)
} }
func player(at index: Int) -> Player? { func player(at index: Int) -> Player? {
@ -140,7 +152,7 @@ final class Table {
let index = index(of: player) let index = index(of: player)
for i in 1..<4 { for i in 1..<4 {
let player = players[(index + i) % 4] let player = players[(index + i) % 4]
guard player.isStillBidding else { guard player.isStillBidding, !player.offersWedding else {
continue continue
} }
return player return player
@ -149,10 +161,14 @@ final class Table {
} }
func remove(player: PlayerName) { func remove(player: PlayerName) {
guard contains(player: player) else { guard let index = players.firstIndex(where: { $0.name == player }) else {
return return
} }
players = players.filter { $0.name != player } let removedPlayer = players[index]
if removedPlayer.playsFirstCard {
players[(index + 1) % players.count].playsFirstCard = true
}
players.remove(at: index)
reset() reset()
} }
@ -183,7 +199,7 @@ final class Table {
didDoubleInCurrentRound = false // Not relevant in this phase didDoubleInCurrentRound = false // Not relevant in this phase
let index = players.firstIndex { $0.playsFirstCard } ?? 0 let index = players.firstIndex { $0.playsFirstCard } ?? 0
for i in 0..<maximumPlayersPerTable { for i in 0..<maximumPlayersPerTable {
players[i].prepareForFirstGame(isFirstPlayer: i == index) players[i].prepareForNewGame(isFirstPlayer: i == index)
} }
} }
@ -217,6 +233,7 @@ final class Table {
player.isNextActor = false player.isNextActor = false
} }
updatePlayableCards() updatePlayableCards()
sendUpdateToAllPlayers()
return .success return .success
} }
@ -240,6 +257,7 @@ final class Table {
func perform(action: Player.Action, forPlayer player: PlayerName) -> PlayerActionResult { func perform(action: Player.Action, forPlayer player: PlayerName) -> PlayerActionResult {
let player = select(player: player)! let player = select(player: player)!
guard player.canPerform(action) else { guard player.canPerform(action) else {
print("Player \(player) wants to \(action.path), but only allowed: \(player.actions)")
return .tableStateInvalid return .tableStateInvalid
} }
defer { sendUpdateToAllPlayers() } defer { sendUpdateToAllPlayers() }
@ -282,9 +300,13 @@ final class Table {
func perform(double: Bool, forPlayer player: Player) -> PlayerActionResult { func perform(double: Bool, forPlayer player: Player) -> PlayerActionResult {
player.didDouble(double) player.didDouble(double)
if allPlayersFinishedDoubling { guard allPlayersFinishedDoubling else {
dealAdditionalCards() return .success
} }
guard initialDoubleExists else {
return dealNextGame()
}
dealAdditionalCards()
return .success return .success
} }
@ -295,20 +317,25 @@ final class Table {
} }
players.forEach { $0.startAuction() } players.forEach { $0.startAuction() }
minimumPlayableGame = nil minimumPlayableGame = nil
phase = .bidding
} }
private func performWeddingCall(forPlayer player: Player) -> PlayerActionResult { private func performWeddingCall(forPlayer player: Player) -> PlayerActionResult {
guard phase == .bidding else { guard phase == .bidding else {
print("Invalid phase \(phase) for wedding call")
return .tableStateInvalid return .tableStateInvalid
} }
guard minimumPlayableGame == nil || minimumPlayableGame == .ruf else { guard minimumPlayableGame == nil || minimumPlayableGame == .ruf else {
print("Invalid minimum game \(minimumPlayableGame!) for wedding call")
return .tableStateInvalid return .tableStateInvalid
} }
guard player.offersWedding else { guard player.offersWedding else {
print("Player does not offer wedding")
return .tableStateInvalid return .tableStateInvalid
} }
guard !weddingOfferExists else { guard !weddingOfferExists else {
// Only one wedding allowed at the table // Only one wedding allowed at the table
print("Already one wedding at table")
return .tableStateInvalid return .tableStateInvalid
} }
// Only allow wedding acceptance or outbidding // Only allow wedding acceptance or outbidding
@ -334,6 +361,8 @@ final class Table {
minimumPlayableGame = .ruf minimumPlayableGame = .ruf
} else { } else {
minimumPlayableGame!.increase() minimumPlayableGame!.increase()
// Remove wedding offers
players.forEach { $0.weddingOutbid() }
} }
player.didPerformBid() player.didPerformBid()
// Find next player to place bid // Find next player to place bid
@ -408,7 +437,7 @@ final class Table {
// Start the game // Start the game
gameType = .hochzeit gameType = .hochzeit
players.forEach { $0.gameStarts() } players.forEach { $0.start(game: .hochzeit) }
player.switchLeadership() player.switchLeadership()
offerer.switchLeadership() offerer.switchLeadership()
return .success return .success
@ -426,18 +455,22 @@ final class Table {
selectGame(player: auctionWinner) selectGame(player: auctionWinner)
case 0: case 0:
// All players withdrawn, deal new cards // All players withdrawn, deal new cards
let first = firstPlayer return dealNextGame()
let newPlayer = self.nextBidder(after: first)
first.playsFirstCard = false
newPlayer.playsFirstCard = true
prepareTableForFirstGame()
return dealInitialCards()
default: default:
break nextBidder(after: player).requiresBid()
} }
return .success return .success
} }
private func dealNextGame() -> PlayerActionResult {
let first = firstPlayer
let newPlayer = self.nextBidder(after: first)
first.playsFirstCard = false
newPlayer.playsFirstCard = true
prepareTableForFirstGame()
return dealInitialCards()
}
private func selectGame(player: Player) { private func selectGame(player: Player) {
minimumPlayableGame = nil minimumPlayableGame = nil
gameType = nil gameType = nil
@ -446,11 +479,43 @@ final class Table {
player.mustSelectGame() player.mustSelectGame()
} }
func select(game: GameType, player: PlayerName) -> PlayerActionResult {
let player = select(player: player)!
guard phase == .selectGame, player.selectsGame, game != .hochzeit else {
return .tableStateInvalid
}
guard minimumPlayableGame == nil || game.gameClass >= minimumPlayableGame! else {
return .tableStateInvalid
}
defer { sendUpdateToAllPlayers() }
guard let suit = game.calledSuit else {
phase = .playing
gameType = game
minimumPlayableGame = nil
players.forEach { $0.start(game: game) }
player.switchLeadership()
return .success
}
guard player.canPlay(game: game) else {
return .tableStateInvalid
}
phase = .playing
gameType = game
minimumPlayableGame = nil
players.forEach { $0.start(game: game) }
player.switchLeadership()
// Find called player
let ace = Card(suit, .ass)
players.first { $0.rawCards.contains(ace) }!.switchLeadership()
return .success
}
private func performDoubleDuringGame(forPlayer player: Player) -> PlayerActionResult { private func performDoubleDuringGame(forPlayer player: Player) -> PlayerActionResult {
guard phase == .playing, guard phase == .playing, !player.isGameLeader else {
!player.isGameLeader else { return .tableStateInvalid
return .tableStateInvalid }
}
player.numberOfRaises += 1 player.numberOfRaises += 1
players.forEach { $0.switchLeadership() } players.forEach { $0.switchLeadership() }
return .success return .success
@ -460,6 +525,9 @@ final class Table {
phase = .waitingForPlayers phase = .waitingForPlayers
gameType = nil gameType = nil
minimumPlayableGame = nil minimumPlayableGame = nil
for player in players {
player.prepareForNewGame(isFirstPlayer: player.playsFirstCard)
}
} }
} }

View File

@ -280,16 +280,27 @@ func routes(_ app: Application) throws {
app.post("player", "action", ":action") { req -> String in app.post("player", "action", ":action") { req -> String in
guard let token = req.body.string, guard let token = req.body.string,
let actionString = req.parameters.get("action"), let actionString = req.parameters.get("action") else {
let action = Player.Action(rawValue: actionString) else {
throw Abort(.badRequest) throw Abort(.badRequest)
} }
switch database.performAction(playerToken: token, action: action) { let result: PlayerActionResult
if let action = Player.Action(rawValue: actionString) {
result = database.performAction(playerToken: token, action: action)
} else if let game = GameType(rawValue: actionString) {
result = database.select(game: game, playerToken: token)
} else {
throw Abort(.badRequest)
}
switch result {
case .success: case .success:
return "" return ""
case .invalidToken: case .invalidToken:
throw Abort(.unauthorized) // 401 throw Abort(.unauthorized) // 401
default: case .noTableJoined:
throw Abort(.preconditionFailed) // 412
case .tableNotFull:
throw Abort(.preconditionFailed) // 412
case .tableStateInvalid:
throw Abort(.preconditionFailed) // 412 throw Abort(.preconditionFailed) // 412
} }
} }