Extending Rule Sets
In this tutorial, we will extend the ISkO with basic Ramsch rules.
The Rules
- The Ramsch will be triggered if all players pass.
- The cards are played like in a normal Grand, i.e. only Jacks are trump.
- The player with the most points is considered the loser. In case two or three players have equal points, all are losers.
- A player taking no tricks is considered Jungfrau.
- If a player takes all tricks, he makes a Durchmarsch.
- If a player makes a Durchmarsch, he has a score of +120
- The losers with p points will have a score of -p. If a player is Jungfrau, they will have a score of -2*p.
Implementing it
As said before, we will extend ISkO.
Let's first create the structure:
extensions/
state.py # Contains the game type and state
rules.py # Contains the modified game logic
The state.py is simple. ISkO does not specify a Ramsch GameType, so we need to create one.
We do not need to store additional attributes in the game state, but for good measure let's create a specific Ramsch game state.
state.py looks like this
| from skaty.isko.state import ISkOGameState, ISkOGameTypes
from skaty.rules import GameType
# Create new game types.
# Inherit the types provided by ISkO (all suits, grand and null).
class RamschGameTypes(ISkOGameTypes):
# Cast the string as GameType for type checking
RAMSCH = GameType("ext:ramsch")
# Inherit all additional state changes from ISkO.
# We do not need additional attributes.
class RamschGameState(ISkOGameState):
pass
|
Now to the more complicated part.
We want to trigger switching game.game_type to RamschGameTypes.RAMSCH if a game is passed.
| from typing import TypeVar
from extensions.ramsch.state import RamschGameState, RamschGameTypes
from skaty.isko.rules import ISkO
from skaty.rules import Action, GameTypes, GamePhases
# Create a new generic for RamschGameState to allow further extension.
T_RamschGameState = TypeVar("T_RamschGameState", bound=RamschGameState)
# Create a custom rule set by extending the standard ISkO rules.
# By passing "T_RamschGameState" as a type argument, we bind the generic base class to our custom state.
# This guarantees strict type safety and full IDE autocomplete in all overridden methods.
# In this case, it makes no difference, because T_RamschGameState adds no attributes.
# You could also just pass RamschGameState, but then RamschRules will not be extensible with new game states.
class RamschRules(ISkO[T_RamschGameState]):
# Override the advance_state method called by every action on apply.
def advance_state(self, state: T_RamschGameState, action: Action) -> None:
# Let ISkO advance the state first. This will handle bidding, declaration and playing.
super().advance_state(state, action)
# If every player passes, game type will be set to PASS.
if state.game_type == GameTypes.PASS:
# Start the Ramsch
state.phase = GamePhases.PLAYING
state.game_type = RamschGameTypes.RAMSCH
state.active_player = state._forehand
|
The playing phase will be handled by ISkO. We just need to change score calculation.
| from typing import TypeVar
from extensions.ramsch.state import RamschGameState, RamschGameTypes
from skaty.isko.rules import ISkO
from skaty.rules import Action, GameTypes, GamePhases
# Add InvalidGameStateError
from skaty.exceptions import InvalidGameStateError
T_RamschGameState = TypeVar("T_RamschGameState", bound=RamschGameState)
class RamschRules(ISkO[T_RamschGameState]):
def calculate_game_score(self, state: T_RamschGameState) -> list[int]:
# We can only calculate a score if the game is finished.
# This is also handled by ISkO.calculate_game_score, but we can't call it now, but it would throw because there is no bid, no declaration and no tops.
# So we will check it first.
if state.phase != GamePhases.GAME_OVER:
raise InvalidGameStateError("A game has no score if it is not finished.")
# We only want to add score calculation for Ramsch.
if state.game_type == RamschGameTypes.RAMSCH:
# See rules
jungfrauen = sum([1 for t in state.tricks_won if t == 0])
# Two players taking no tricks is equivalent to one player taking all tricks.
durchmarsch = jungfrauen == 2
if durchmarsch:
# If a player took all tricks, he must have 120 points and the others must have 0
return state.points
# jungfrauen can now only be one, because it can be either be 2 (Durchmarsch, handled above) or 1
multiplier = jungfrauen + 1
max_points = max(state.points)
# Only keep the maximal points for the losers. Make them negative and multiply them by two if Jungfrau. Others get 0 points.
scores = [
-multiplier * max_points if i == max_points else 0 for i in state.points
]
return scores
# For every other game type, let ISkO handle it.
return super().calculate_game_score(state)
def advance_state(self, state: T_RamschGameState, action: Action) -> None:
super().advance_state(state, action)
if state.game_type == GameTypes.PASS:
state.phase = GamePhases.PLAYING
state.game_type = RamschGameTypes.RAMSCH
state.active_player = state._forehand
|
Using it
Using our new rule set is with the exception of using the new classes, the same as in the Getting Started.
| import random
from extensions.ramsch.rules import RamschRules
from extensions.ramsch.state import RamschGameState
from skaty.isko.actions import DeclareBid, Pass
from skaty.rules import GamePhases
# Use our new Rules instead of ISkO
rule_set = RamschRules()
# For simplicity, set dealer to 0
dealer = 0
game = RamschGameState.from_random_deal(rule_set, dealer, log=True)
# Ramsch game
print("Start Ramsch game")
# We can still use ISkO's actions.
# All players pass to trigger a Ramsch game.
game.apply_action(Pass(2))
game.apply_action(Pass(0))
game.apply_action(Pass(1))
# We will play exactly 30 cards
for _ in range(30):
active_p = game.active_player
actions = list(game.get_valid_actions(active_p))
action = random.choice(actions)
game.apply_action(action)
# Print result
print(game.calculate_game_score())
print("-" * 20)
print("UNDOING")
# Now let's undo all actions and start a suit game.
for _ in range(33):
game.undo_action()
print("-" * 20)
print("Start non-Ramsch game with the same rule set")
# Make at least one bid to force non-Ramsch game.
game.apply_action(DeclareBid(2, 18))
# This is the same as in Getting Started
while game.phase != GamePhases.GAME_OVER:
active_p = game.active_player
actions = list(game.get_valid_actions(active_p))
action = random.choice(actions)
game.apply_action(action)
print(
f"Game ended with score {game.calculate_game_score()[game.declarer_idx]} for player {game.declarer_idx}"
)
|