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

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

View File

@ -13,14 +13,24 @@ struct TableInfo: Codable {
let playerAcross: PlayerInfo?
let playerRight: PlayerInfo?
let playableGames: [GameId]
init(_ table: Table, forPlayerAt playerIndex: Int) {
let player = table.player(at: playerIndex)!
self.id = table.id
self.name = table.name
self.player = table.player(at: playerIndex)!.info(masked: false)
self.playerLeft = table.player(leftOf: playerIndex)?.info(masked: true)
self.playerAcross = table.player(acrossOf: playerIndex)?.info(masked: true)
self.playerRight = table.player(rightOf: playerIndex)?.info(masked: true)
self.player = table.playerInfo(at: playerIndex, masked: false)!
self.playerLeft = table.playerInfo(leftOf: playerIndex, masked: true)
self.playerAcross = table.playerInfo(acrossOf: playerIndex, 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)
}
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 {
guard let player = players.registeredPlayerExists(withSessionToken: playerToken) else {
return .invalidToken

View File

@ -167,11 +167,20 @@ final class TableManagement: DiskWriter {
func performAction(player: PlayerName, action: Player.Action) -> PlayerActionResult {
guard let table = currentTable(for: player) else {
print("Player \(player) wants to \(action.path), but no table joined")
return .noTableJoined
}
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 {
guard let table = currentTable(for: player) else {
return .noTableJoined

View File

@ -26,7 +26,7 @@ extension CardOrder {
static func highCardIndex(cards: [Card]) -> Int {
let high: Card
if isTrump(cards[0]) {
if hasTrump(in: cards) {
high = sort(cards).first!
} else {
let suit = cards.first!.suit
@ -66,4 +66,8 @@ extension CardOrder {
static func cards(with suit: Card.Suit, in cards: [Card]) -> [Card] {
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
enum GameType: Codable {
enum GameType: String, CaseIterable, Codable {
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)!
}
}
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
}
}
case rufEichel = "ruf-eichel"
case rufBlatt = "ruf-blatt"
case rufSchelln = "ruf-schelln"
case hochzeit = "hochzeit"
case bettel = "bettel"
case wenz = "wenz"
case geier = "geier"
case soloEichel = "solo-eichel"
case soloBlatt = "solo-blatt"
case soloHerz = "solo-herz"
case soloSchelln = "solo-schelln"
var isCall: Bool {
switch self {
@ -70,7 +35,7 @@ enum GameType: Codable {
return nil
}
}
var isSingleGame: Bool {
switch self {
case .rufEichel, .rufBlatt, .rufSchelln, .hochzeit:
@ -83,6 +48,10 @@ enum GameType: Codable {
var basicCost: Int {
gameClass.cost
}
var id: GameId {
rawValue
}
var sortingType: CardOrder.Type {
switch self {

View File

@ -86,7 +86,9 @@ final class Player {
func play(card: Card) {
remove(card: card)
playedCard = card
actions = actions.filter { $0 != .doubleDuringGame }
}
func connect(using socket: WebSocket) {
_ = self.socket?.close()
self.socket = socket
@ -113,11 +115,15 @@ final class Player {
actions.contains(action)
}
func prepareForFirstGame(isFirstPlayer: Bool) {
func prepareForNewGame(isFirstPlayer: Bool) {
playsFirstCard = isFirstPlayer
isNextActor = isFirstPlayer
selectsGame = false
startedCurrentTrick = isFirstPlayer
actions = [.deal]
didDoubleAfterFourCards = nil
isStillBidding = true
isGameLeader = false
handCards = []
playedCard = nil
wonTricks = []
@ -152,6 +158,7 @@ final class Player {
func offerWedding() {
offersWedding = true
isStillBidding = false
actions = []
}
@ -171,14 +178,8 @@ final class Player {
func weddingOutbid() {
isNextActor = false
guard isStillBidding else {
return
}
actions = []
if offersWedding {
offersWedding = false
isStillBidding = false
}
offersWedding = false
}
func didPerformBid() {
@ -221,8 +222,26 @@ final class Player {
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() {
isNextActor = true
selectsGame = true
}
func replace(card: Card, with other: Card) {
@ -237,11 +256,15 @@ final class Player {
return removed
}
func gameStarts() {
func start(game: GameType) {
isNextActor = playsFirstCard
startedCurrentTrick = playsFirstCard
selectsGame = false
actions = [.doubleDuringGame]
isGameLeader = false
if playsFirstCard {
setPlayableCardsForStarter(game: game)
}
}
func switchLeadership() {
@ -254,6 +277,7 @@ final class Player {
}
func withdrawFromBidding() {
isNextActor = false
isStillBidding = false
actions = []
}
@ -263,6 +287,8 @@ final class Player {
playedCard = nil
if canDoubleInNextRound, !isGameLeader {
actions = [.doubleDuringGame]
} else {
actions = []
}
}
@ -390,8 +416,8 @@ final class Player {
}
}
func info(masked: Bool) -> PlayerInfo {
.init(player: self, isMasked: masked)
func info(masked: Bool, positionInTrick: Int) -> PlayerInfo {
.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 {
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 }
}
/// At least one double exists after all players acted on their first cards
var initialDoubleExists: Bool {
players.contains { $0.didDoubleAfterFourCards == true }
}
var weddingOfferExists: Bool {
players.contains { $0.offersWedding }
}
@ -105,17 +110,24 @@ final class Table {
func select(player: PlayerName) -> Player? {
players.first { $0.name == player }
}
func player(leftOf index: Int) -> Player? {
player(at: (index + 1) % 4)
func playerInfo(at index: Int, masked: Bool) -> PlayerInfo? {
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? {
player(at: (index + 2) % 4)
func playerInfo(leftOf index: Int, masked: Bool) -> PlayerInfo? {
playerInfo(at: (index + 1) % 4, masked: masked)
}
func player(rightOf index: Int) -> Player? {
player(at: (index + 3) % 4)
func playerInfo(acrossOf index: Int, masked: Bool) -> PlayerInfo? {
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? {
@ -140,7 +152,7 @@ final class Table {
let index = index(of: player)
for i in 1..<4 {
let player = players[(index + i) % 4]
guard player.isStillBidding else {
guard player.isStillBidding, !player.offersWedding else {
continue
}
return player
@ -149,10 +161,14 @@ final class Table {
}
func remove(player: PlayerName) {
guard contains(player: player) else {
guard let index = players.firstIndex(where: { $0.name == player }) else {
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()
}
@ -183,7 +199,7 @@ final class Table {
didDoubleInCurrentRound = false // Not relevant in this phase
let index = players.firstIndex { $0.playsFirstCard } ?? 0
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
}
updatePlayableCards()
sendUpdateToAllPlayers()
return .success
}
@ -240,6 +257,7 @@ final class Table {
func perform(action: Player.Action, forPlayer player: PlayerName) -> PlayerActionResult {
let player = select(player: player)!
guard player.canPerform(action) else {
print("Player \(player) wants to \(action.path), but only allowed: \(player.actions)")
return .tableStateInvalid
}
defer { sendUpdateToAllPlayers() }
@ -282,9 +300,13 @@ final class Table {
func perform(double: Bool, forPlayer player: Player) -> PlayerActionResult {
player.didDouble(double)
if allPlayersFinishedDoubling {
dealAdditionalCards()
guard allPlayersFinishedDoubling else {
return .success
}
guard initialDoubleExists else {
return dealNextGame()
}
dealAdditionalCards()
return .success
}
@ -295,20 +317,25 @@ final class Table {
}
players.forEach { $0.startAuction() }
minimumPlayableGame = nil
phase = .bidding
}
private func performWeddingCall(forPlayer player: Player) -> PlayerActionResult {
guard phase == .bidding else {
print("Invalid phase \(phase) for wedding call")
return .tableStateInvalid
}
guard minimumPlayableGame == nil || minimumPlayableGame == .ruf else {
print("Invalid minimum game \(minimumPlayableGame!) for wedding call")
return .tableStateInvalid
}
guard player.offersWedding else {
print("Player does not offer wedding")
return .tableStateInvalid
}
guard !weddingOfferExists else {
// Only one wedding allowed at the table
print("Already one wedding at table")
return .tableStateInvalid
}
// Only allow wedding acceptance or outbidding
@ -334,6 +361,8 @@ final class Table {
minimumPlayableGame = .ruf
} else {
minimumPlayableGame!.increase()
// Remove wedding offers
players.forEach { $0.weddingOutbid() }
}
player.didPerformBid()
// Find next player to place bid
@ -408,7 +437,7 @@ final class Table {
// Start the game
gameType = .hochzeit
players.forEach { $0.gameStarts() }
players.forEach { $0.start(game: .hochzeit) }
player.switchLeadership()
offerer.switchLeadership()
return .success
@ -426,18 +455,22 @@ final class Table {
selectGame(player: auctionWinner)
case 0:
// All players withdrawn, deal new cards
let first = firstPlayer
let newPlayer = self.nextBidder(after: first)
first.playsFirstCard = false
newPlayer.playsFirstCard = true
prepareTableForFirstGame()
return dealInitialCards()
return dealNextGame()
default:
break
nextBidder(after: player).requiresBid()
}
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) {
minimumPlayableGame = nil
gameType = nil
@ -446,11 +479,43 @@ final class Table {
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 {
guard phase == .playing,
!player.isGameLeader else {
return .tableStateInvalid
}
guard phase == .playing, !player.isGameLeader else {
return .tableStateInvalid
}
player.numberOfRaises += 1
players.forEach { $0.switchLeadership() }
return .success
@ -460,6 +525,9 @@ final class Table {
phase = .waitingForPlayers
gameType = 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
guard let token = req.body.string,
let actionString = req.parameters.get("action"),
let action = Player.Action(rawValue: actionString) else {
let actionString = req.parameters.get("action") else {
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:
return ""
case .invalidToken:
throw Abort(.unauthorized) // 401
default:
case .noTableJoined:
throw Abort(.preconditionFailed) // 412
case .tableNotFull:
throw Abort(.preconditionFailed) // 412
case .tableStateInvalid:
throw Abort(.preconditionFailed) // 412
}
}