Designs and Philosophy
This page discusses the designs and philosophy behind dgisim and why some
design choices are made.
Certain parts of the page might be simplified for easier understanding.
Objectives
The objective of the project is to provide an reliable, easy-to-use, and type-safe package for building RL and other applications of Genius Invokation TCG.
This means the simulator needs to be both performant and robust.
For the purposes above:
Immutable data structures were used to simpify the error-prone part of mutability, also making storing history game states costs less memory. (with optimizations)
Type hints are well used everywhere in the project, to help people choose valid inputs.
Unit tests cover almost all lines of code, to ensure behaviours of implemented content always performs as expected.
Normal Game Flow
dgisim models the game Genius Invokation TCG as a state machine.
Each state is represented by the GameState class.
GameState is the essence of this project, by understanding how it works,
you can easily get familiar with all other classes.
Below shows how running a normal gameplay is like with a GameState instance.
What a GameState Contains
Below shows what a GameState instance contains in pseudocode.
class GameState:
mode: Mode
phase: Phase
round: int
active_player_id: Pid
player1: PlayerState
player2: PlayerState
effect_stack: EffectStack
Let’s go over each of its attributes.
mode: contains information about the game mode of the entire gamephase: contains all the logics for handling how the game state make the next transition. e.g. executing an existingeffect, asking for player action, transit to next phase based on themoderound: an int representing which round the current game is inactive_player_id: tells which player is the active player in this game stateplayer1: contains all information about the player, including characters, summons, and so onplayer2: the other player which is the opponent ofplayer1effect_stack: contains theeffects waiting to be executed. Eacheffectcan transit the game state to the next as programmed. e.g. a damage effect deals damage to opponent, a swap character effect changes the active character of a player…
How GameState Makes a Transition
Transition Without Player Action
GameState passes itself to phase it contains,
and let phase make the transition.
phase first checks if the transition request is valid.
(Checks if player action is required or the game has ended)
If the transition request is valid, then a new GameState instance
is returned based on the one passed in above.
phase may make changes like, removing and executing an effect, changing player state,
move on to the next phase based on mode by assigning the new game state with a
new Phase.
Transition With Player Action
GameState passes itself and the player action got to phase it contains,
and let phase make the transition.
phase first checks if the player action is expected at the current state,
also checks if the action self is valid.
(Is the card played is in hand?
Is the player allowed to take an action?
Has enough dice be paid for the action…)
Then phase make changes like, pushing new effects to the effect_stack…
Phase Transitions of Default Game Mode
How Player Actions are Handled
Example: Play the Card “Mondstadt Hash Brown”
Let’s start with a simple example when the card “Mondstadt Hash Brown” is played.
As described above, GameState passes itself and the player’s card action to
phase which must be Action Phase in this case.
From the player action, phase can know:
Which card the player wants to play.
Which target the card is used on.
Which dice the player wants to use to pay for the action.
phase then go over each piece of information to check if the action is valid.
Does the player has “Mondstadt Hash Brown” in hand?
Is the target an alive character of this player that is not satiated?
Can the dice pay for the card and does the player have the dice they stated?
If everything goes fine, then a number of things happen.
Dice paid are removed.
Effects of the card are pushed to the
effect_stack.
Note that all changes above is done to a copy of the current game state, and the modified copy is then returned as the next state.
The effects added for this “Mondstadt Hash Brown” looks like this. (in execution ordered)
1. PublicRemoveCardEffect
- pid: P2
- card: MondstadtHashBrown
2. RecoverHPEffect
- target: {pid: P2, zone: Characters, character_id: 1}
- recovery: 2
3. AddCharacterStatusEffect
- target: {pid: P2, zone: Characters, character_id: 1}
- status: SatiatedStatus
The effects should be quite self-explanatory, except the part in {...}.
That is just the internal way to specify a particular target in the game,
which is a character with id 1 of player2 in this case.
Example: Play the Card “Cold-Blooded Strike”
The action is handled quite similar to how “Mondstadt Hash Brown” is handled above.
The effects are:
1. PublicRemoveCardEffect
- pid: P1
- card: ColdBloodedStrike
2. AddCharacterStatusEffect
- target: {pid: P1, zone: Characters, character_id: 1}
- status: ColdBloodedStrikeStatus
3. CastSkillEffect
- target: {pid: P1, zone: Characters, character_id: 1}
- skill: ElementalSkill1
4. AllStatusTriggererEffect
- pid: P1
- signal: CombatAction
5. TurnEndEffect
PublicRemoveCardEffect is executed first to remove the card.
Then AddCharacterStatusEffect adds the ColdBloodedStrikeStatus to this character.
After that, CastSkillEffect is executed to generate the effects for the skill.
If the target character cannot cast the skill when the effect is executed,
then no effects are generated.
So after CastSkillEffect is executed, the effect_stack looks like this:
1. ReferredDamageEffect
- source: {pid: P1, zone: Characters, character_id: 1}
- target: OppoActive
- element: Cryo
- damage: 3
- damage_type: ElementalSkill
2. EnergyRechargeEffect
- target: {pid: P1, zone: Characters, character_id: 1}
- recharge: 1
3. BroadCastSkillInfoEffect
- source: {pid: P1, zone: Characters, character_id: 1}
- skill: ElementalSkill1
4. DeathCheckCheckerEffect
5. AllStatusTriggererEffect
- pid: P1
- signal: CombatAction
6. TurnEndEffect
The first two effects should be somewhat obvious.
BroadCastSkillInfoEffect notifies all statuses that some event has happened,
some statuses may save the notification inside themselves for later use.
DeathCheckCheckerEffect checks if the active character of any player is dead.
If so, some effects are added to handle the ‘inserted’ death swap.
AllStatusTriggererEffect generates triggering effects for each status in current
game state in order according to the game’s rule.
Each status may respond to the triggering effect by adding more effects to the stack.
Whether respond or not depends on the implementation of each status.
In this case, ColdBloodedStrikeStatus has been broadcasted about the cast of the skill
from its equipper before, so it emits some effects to heal the equipper as well as
updating itself as used in this round.
TurnEndEffect switches the player in action. That is make player2 the active
player in this case.
Player Phase
Player phase determines the phase each player is in.
The two examples above should give you an impression how powerful the effect handling system can be. But not all logics of the game are handled by effects.
Aside from the Game phase (Roll phase, Action phase…) that determines the state of the game, each player has their own state, mainly used to mark the phase of them inside the game phase.
ACTION_PHASE: the player is in actionPASSIVE_WAIT_PHASE: the player is waiting to be inACTION_PHASEACTIVE_WAIT_PHASE: the player is waiting but more active thanPASSIVE_WAIT_PHASEEND_PHASE: the player is all done for this game phase
Typically, when a game phase is about to transit to the next phase,
both phases of the players are END_PHASE.
And when the game state just transits to a new phase,
both phases of the players are PASSIVE_WAIT_PHASE waiting to be assigned
some new phase by the game phase instance.
Below shows how phases controls the flow inside action phase of the game.
(1AP;2PWP means player1 is in ACTION_PHASE, and player2 is in PASSIVE_WAIT_PHASE)
Note that the diagram doesn’t include the handling of death-swaps for simplicity. (the insertion of request for player action because their active character is defeated)
Whenever the effect which checks for the death of the active character of any player
detects a death. Two effects are pushed to the effect_stack -
DeathSwapPhaseStartEffect and DeathSwapPhaseEndEffect.
The former one is caught by game’s action phase,
indicating the corresponding player action is required to proceed.
The latter one saves the phases of each player at the time when DeathSwap happens,
restoring the original phases when it is executed.
Player Actions
A PlayerAction is what that can be processed by the GameState as an input from the player.
Each phase has a method called action_generator().
def action_generator(self, game_state: GameState, pid: Pid) -> None | ActionGenerator:
...
Given a game state and the pid of the player who wants to make an action,
it returns an instance of ActionGenerator,
which is a class used to help generate ‘correct’ player actions.
(note that this is another immutable class)
The ActionGenerator has a few methods listed below.
class ActionGenerator:
# note that the fields below are only readable (immutable)
game_state: GameState # the game state that action generator used to refer to
pid: Pid # the pid of the player who makes the action
def filled(self) -> bool:
""" Returns True if a PlayerAction is ready to be generated """
...
def generate_action(self) -> PlayerAction:
"""
Returns the generated PlayerAction
This method asserts self.filled() is True
"""
...
def choices(self) -> GivenChoiceType:
"""
Returns the choices that the user can make from
GivenChoiceType is a type alias for a whole loads of types, you can find its
definition below.
"""
...
def choose(self, choice: DecidedChoiceType) -> ActionGenerator:
"""
Returns the action generator that have the new choice provided recorded
An exception is raised if the choice is invalid
DecidedChoiceType is another type alias defined below
"""
...
#### type aliases ####
_SingleChoiceType = (
StaticTarget # a reference of a target in the game
| int
| ActualDice
| CharacterSkill # enum of skill types
| type[Card]
| Element
| ActionType # the type of a player action
)
GivenChoiceType = tuple[_SingleChoiceType, ...] | ActualDice | AbstractDice | Cards
DecidedChoiceType = _SingleChoiceType | ActualDice | Cards
Based on the comments you should be able to tell what each method is for, but the type aliases by the end may seem like a mass. Don’t worry, it’s quite simple.
If
GivenChoiceTypereturns atuple, then you are expected to choose one item from thetupleas the chosen choice.If
GivenChoiceTypereturnsActualDice, then you are expected to choose some of the dice from the returned one. (As to how many and which dice to choose is based on the context that needs to be judged by the user)If
GivenChoiceTypereturnsAbstractDice, then you are expected to provide someActualDicethat can satisfy theAbstractDice. (the concept ofAbstractDiceandActualDicewill be discussed later)If
GivenChoiceTypereturnsCards, then you are expected to choose someCardsfrom the returned one.
So the workflow to use an ActionGenerator is like this:
game_state: GameState = ... # you should have the game state to generate action from
# suppose you are making a choice for player 1
action_generator = game_state.action_generator(Pid.P1) # this is an 'alias' of
# game_state.get_phase(
# ).action_generator(game_state)
while not action_generator.filled():
choices = action_generator.choices()
choice = ... # write some code to make a wise choice
action_generator = action_generator.choose(choice)
player_action = action_generator.generate_action()
# then you can use it to make a transition
# e.g. new_game_state = game_state.action_step(Pid.P1, player_action)
The example above is a linear choice maker, that is it only generates one player_action by the end.
To implement an algorithm to generate all possible player actions
(or at least explore a few branches).
You should save the old action_generator s by recursion or whatever to memorize
the history as a tree.
That concludes the section of ActionGenerator,
it is but a helper to generate correct PlayerAction s,
you may write your own algorithm to directly generate a correct one without ActionGenerator
and pass it to the game state any time.